Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be15dd05df | ||
|
|
3578e4019b | ||
|
|
1ab8f113a5 | ||
|
|
4f4ea43549 | ||
|
|
4fd67c52dc | ||
|
|
a3b06befbc | ||
|
|
290f39ecb8 | ||
|
|
7fbe7593c8 | ||
|
|
9fb61f8e99 | ||
|
|
2287abe644 | ||
|
|
32bd2118af | ||
|
|
f290a2101e | ||
|
|
f26806bc55 | ||
|
|
c53e1f465d | ||
|
|
8609963ef8 | ||
|
|
b334794168 | ||
|
|
7186a69b78 | ||
|
|
9c202c7eae | ||
|
|
2969fb3c21 | ||
|
|
149a2481f4 | ||
|
|
4da2f382ec | ||
|
|
a3a96a6bd4 | ||
|
|
548809f72e | ||
|
|
7568d05434 | ||
|
|
6aaa15be3c | ||
|
|
ac8e41aa5f | ||
|
|
57bc1bcea8 | ||
|
|
f1c5a887b6 | ||
|
|
7c76e4fc03 | ||
|
|
606f712a52 | ||
|
|
dac4f6c029 | ||
|
|
b7a6abb780 | ||
|
|
7dab8df861 | ||
|
|
288212748c | ||
|
|
ef2c3ac163 | ||
|
|
b057c89a70 | ||
|
|
6ddfa59a28 | ||
|
|
721a40b05e | ||
|
|
d04511b8a6 | ||
|
|
f913c95ed1 | ||
|
|
effde76227 | ||
|
|
43f0813acd | ||
|
|
33d8cd7e36 | ||
|
|
cfbe5b9b0d | ||
|
|
6e85983ad4 | ||
|
|
edbb175d4e | ||
|
|
b7bc0acb14 | ||
|
|
48e5d603bc | ||
|
|
12ff9d6138 | ||
|
|
ea18feb0a8 | ||
|
|
c5e3c2ec56 | ||
|
|
92c80f81f9 | ||
|
|
70ed89c6bf | ||
|
|
07b14aa72a | ||
|
|
d245ea4c84 | ||
|
|
9e3a5d6fbd | ||
|
|
33623d09d9 | ||
|
|
b20055a0b5 | ||
|
|
caf81b7984 | ||
|
|
fc05976d33 | ||
|
|
021da02474 | ||
|
|
a5b9e5a13f | ||
|
|
5742239c74 | ||
|
|
3dce49020e | ||
|
|
4ec2635830 | ||
|
|
f319906f29 | ||
|
|
fac92c252e | ||
|
|
9f8c525008 | ||
|
|
207d4dd7e5 | ||
|
|
8db297fe4b | ||
|
|
dc2aec7696 | ||
|
|
9a6b7d4d41 | ||
|
|
404af112b7 | ||
|
|
5947016cc1 | ||
|
|
5425d1565f | ||
|
|
386426bb92 | ||
|
|
1a58e3c3e6 | ||
|
|
3cea671a54 | ||
|
|
9d8b338833 | ||
|
|
ea92e0212b | ||
|
|
f06ea4d9e2 | ||
|
|
93dbd768f7 | ||
|
|
857279e076 | ||
|
|
03838a3430 | ||
|
|
0d34150333 | ||
|
|
9e8b58cbb6 | ||
|
|
fe724e96e8 | ||
|
|
f72979f4a9 | ||
|
|
0b012788c7 | ||
|
|
3f2fc2e5f0 | ||
|
|
ef27877628 | ||
|
|
7b7bd556e9 | ||
|
|
6a2e9dd10e | ||
|
|
d534ee242c | ||
|
|
42b5004bf8 | ||
|
|
3a3cf00072 | ||
|
|
90473284b8 | ||
|
|
07aa2c883a | ||
|
|
5abef59c7c | ||
|
|
8aee08268d | ||
|
|
6a4744073c |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AI Table server-side statistics** — adds `dws aitable record stats` for
|
||||
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
|
||||
record group-stats` for grouped, distinct, and advanced aggregation through
|
||||
`query_stats`; both commands validate their JSON aggregation contracts before
|
||||
dispatch.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
|
||||
entries with lark-cli where the DingTalk backend supports equivalent
|
||||
semantics, rejects malformed or missing collections instead of returning
|
||||
false empty success, preserves truthful pagination, and requires stable
|
||||
identifiers plus read-back or explicit terminal receipts for writes. Adds
|
||||
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
|
||||
capability review with documented platform boundaries.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
|
||||
`comment resolve`, `comment restore`, and the lightweight
|
||||
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
|
||||
the same `doc-comment` MCP capabilities; batch queries preserve input order
|
||||
for repeated `topicId:commentKey` references, while reaction replies require
|
||||
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
|
||||
@@ -41,7 +41,7 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-14T13:50:36.324505",
|
||||
"count": 418,
|
||||
"generated_at": "2026-08-17T15:52:13.675461",
|
||||
"count": 435,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -1056,144 +1056,274 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+agenda",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 result.events 与分页证据,稳定投影 id 为 eventId;显式空数组才是空日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+attendee-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证参会人数组并投影显示名、响应状态和 self;真实后端不返回稳定参会人 userId,因此不伪造身份字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按参会人姓名解析后创建日程,并在多步骤路径提供失败回滚与详情读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证日历本数组并稳定投影 calendarId,缺失数组不再误报为空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按日历本名称搜索并严格区分零命中与协议错误。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+cancel-event",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "删除前读取并展示目标,经高风险确认后取消指定日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+conflicts",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在指定范围分析重叠日程并输出冲突对,避免人工逐项比对。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建响应必须给出 eventId/id,且标题和起止时间通过详情读回后才成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+free",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析参与者后查询闲忙,省去手工 userId 解析。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+free-slots",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "从已有日程计算工作时段内空档,并显式输出满足时长的候选区间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+freebusy",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一按用户或会议室 ID 查询指定范围闲忙,并声明至少一类目标约束。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "严格读取日程对象并把稳定 id 规范化为 eventId,拒绝空响应和对象漂移。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+invite",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析用户后邀请到已有日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+my-free",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "以当前用户身份查询一个时间段是否空闲。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+next-event",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在未来时间窗内定位最近的下一场日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+reschedule",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "修改前读取目标日程,再更新完整起止时间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-find",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按单个未来时间段严格查询可用会议室,公开真实 page/pageSize/hasMore 证据和后端筛选边界。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-groups",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.groupList,避免会议室分组被响应 wrapper 漂移静默清空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称查询会议室但不声称检查时间可用性;缺失会议室数组直接失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+rsvp",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把直白 RSVP 动作映射为 responseStatus,拒绝空 ack,并在写后读取日程终态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+search-event",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在服务端日程当前页按标题、描述和地点过滤,同时保留 hasMore/nextCursor,绝不把单页零命中当全局未找到。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+suggest-time",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名逐个唯一解析参与者,再查询大家共同可用的建议时间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+suggestion",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "直接按 userId 查询建议时段,严格区分显式空 recommendEventTimes 与缺失或畸形响应。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+today",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并整理今天的日程视图。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+tomorrow",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并整理明天的日程视图。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "组合字段与参会人分阶段更新,明确非事务步骤并以详情和参会人列表读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+week",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并按日期整理本周日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3582,81 +3712,214 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+assign",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "保留历史按姓名解析后创建并指派的新任务语义,避免同名破坏性变更。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+assign-multi",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "全部姓名唯一解析后才创建一次,并以单一统一结果输出稳定 taskId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+comment",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "新增评论必须取得稳定 commentId,并分页读回验证内容。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+complete",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先读当前状态避免重复写,必要时完成后再读回核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建后要求稳定 taskId,并读取 todoDetailModel 验证标题。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+created-todos",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历创建者角色的全部分页后投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+due-today",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按本地日历日构造服务端截止窗口并遍历全部分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格绑定 todoDetailModel.taskId,缺失或错绑均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get-my-tasks",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格识别 todoCards/hasMore/page/size;显式空数组才是空结果,并支持有界全量分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get-related-tasks",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历与我相关三种角色的全部分页并按 taskId 去重。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-attachment",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空附件与缺失容器,并要求每项稳定附件 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-comment",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空评论与缺失容器,并要求每项稳定 commentId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-sub",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验子待办容器、对象元素和稳定 taskId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+overdue",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历执行者角色的全部分页并按当前时间筛选逾期项。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+related-tasks",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留旧命令拼写,新的 Agent 路由统一使用 +get-related-tasks。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+remind",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "解析当前用户后创建给自己的待办;该命令不是独立提醒规则。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+reminder",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证提醒参数和 success=true 终端回执;上游不能读回规则,固定 verified=false。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+reopen",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先读当前状态避免重复写,必要时重开后再读回核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历全部 hasMore 分页并按标题匹配;达到页上限仍未耗尽时失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+todo-done",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "跨全部分页按标题唯一定位后完成任务。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>wiki +wiki-new-doc --space <名></code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --content</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +share-doc --to <名> --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -56,13 +56,13 @@
|
||||
|
||||
| shortcut | 多步/智能逻辑 | 验证 |
|
||||
|----------|--------------|------|
|
||||
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `chat +dm --to <姓名> --content` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
|
||||
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
|
||||
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `chat +send-to-group --group <群名> --content` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
|
||||
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
|
||||
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `chat +broadcast --to <姓名CSV> --content` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
|
||||
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
|
||||
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
|
||||
@@ -76,7 +76,7 @@
|
||||
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
|
||||
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
|
||||
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --content` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
|
||||
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
|
||||
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# OA Attachment Download URL Output Design
|
||||
|
||||
## Goal
|
||||
|
||||
Keep the existing command and MCP request unchanged while making the returned
|
||||
OSS signed URL directly copyable from JSON output:
|
||||
|
||||
```text
|
||||
dws oa approval attachment download-url
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
Only `oa approval attachment download-url` changes. The other OA attachment
|
||||
commands and the global JSON formatter retain their current behavior.
|
||||
|
||||
## Design
|
||||
|
||||
The command continues to invoke MCP server `oa`, tool
|
||||
`get_attachment_download_url`, with the same arguments. Its leaf declaration
|
||||
provides a command-specific `Call` callback that invokes the existing MCP
|
||||
dispatcher with HTML escaping disabled when the selected output format is
|
||||
JSON. This preserves literal `&` separators in `result.downloadUri` instead of
|
||||
rendering them as `\u0026`.
|
||||
|
||||
For `raw`, `table`, and other non-JSON formats, the callback uses the existing
|
||||
escaped dispatcher behavior so their current rendering remains unchanged.
|
||||
|
||||
The change does not alter the URL, decode or re-sign it, download the file, or
|
||||
change global JSON serialization.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Authentication, MCP transport, gateway, PAT, and business errors continue
|
||||
through the existing dispatcher and retain their current classification and
|
||||
output behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
Add a `TestCrossPlatformCoverage*` regression test that executes the real Cobra
|
||||
leaf in explicit JSON mode with a fake MCP result containing a signed URL. It
|
||||
must verify:
|
||||
|
||||
- the request still targets `oa/get_attachment_download_url`;
|
||||
- the exact request arguments remain unchanged, including omission of the
|
||||
optional boolean when the flag was not supplied;
|
||||
- stdout contains literal `&OSSAccessKeyId=` and `&Signature=`;
|
||||
- stdout contains no `\u0026` escape.
|
||||
|
||||
The fake caller must report JSON format (or the command must be executed with
|
||||
`--format json`) so the test fails against the current escaped JSON path rather
|
||||
than accidentally exercising raw MCP text output.
|
||||
|
||||
Run the focused OA attachment tests, format modified Go files, and rebuild the
|
||||
CLI. No commit is created.
|
||||
@@ -68,6 +68,8 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"revertedToVersion":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "list_workflows":
|
||||
return `{"workflows":[]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "list_files":
|
||||
|
||||
@@ -27,12 +27,23 @@ const (
|
||||
// its spelling while holding every other input constant.
|
||||
var paramAliasCompleteCommands = map[string][]string{
|
||||
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
|
||||
"aitable +export-data": {"aitable", "+export-data", "--base-id", "base-1", "--scope", "all", "--format", "excel"},
|
||||
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
|
||||
"aitable +find-record": {"aitable", "+find-record", "--base", "base-1", "--table", "table-1", "--query", "fixture"},
|
||||
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
|
||||
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
|
||||
"aitable +record-share-links": {"aitable", "+record-share-links", "--base", "base-1", "--table", "table-1", "--record-ids", "record-1"},
|
||||
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
|
||||
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
|
||||
"aitable +workflow-list": {"aitable", "+workflow-list", "--base-id", "base-1", "--limit", "7"},
|
||||
"aitable attachment upload": {"aitable", "attachment", "upload", "--base-id", "base-1", "--file-name", "fixture.txt", "--size", "7"},
|
||||
"aitable base list": {"aitable", "base", "list", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"aitable base update": {"aitable", "base", "update", "--base-id", "base-1", "--name", "Fixture Base", "--desc", "fixture description"},
|
||||
"aitable field search-options": {"aitable", "field", "search-options", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--keyword", "fixture", "--limit", "7"},
|
||||
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
|
||||
"aitable workflow get": {"aitable", "workflow", "get", "--base-id", "base-1", "--workflow-id", "workflow-1"},
|
||||
"aitable workflow history": {"aitable", "workflow", "history", "--base-id", "base-1", "--workflow-id", "workflow-1", "--after-time", "1000", "--before-time", "2000", "--page", "2", "--size", "25"},
|
||||
"aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
@@ -62,12 +73,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--content", "hello fixture", "--yes"},
|
||||
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
|
||||
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
|
||||
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
@@ -93,11 +104,11 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
|
||||
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
|
||||
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
|
||||
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
|
||||
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
|
||||
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
|
||||
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
@@ -116,7 +127,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
|
||||
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--content", "fixture appendix", "--yes"},
|
||||
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
|
||||
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
|
||||
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
|
||||
@@ -132,8 +143,8 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--content", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--content", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
@@ -196,7 +207,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
},
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--content", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"doc +inspect": {
|
||||
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
|
||||
@@ -219,8 +230,8 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
},
|
||||
"chat message send": {
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
|
||||
"file": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
@@ -260,7 +271,6 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
|
||||
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
|
||||
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
|
||||
{command: "chat message send", emitted: "file", canonical: "file-path"},
|
||||
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
|
||||
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
|
||||
@@ -278,7 +288,7 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
|
||||
{command: "chat +messages-list", emitted: "start", canonical: "time"},
|
||||
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "group"},
|
||||
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
|
||||
@@ -372,16 +382,113 @@ var paramAliasNewDriveCases = []struct {
|
||||
{command: "drive +upload", emitted: "file-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
|
||||
// every Drive command in the expansion whose declared runtime safety requires
|
||||
// confirmation. The full matrix below proves all spellings preserve the
|
||||
// confirmed payload; this smaller matrix proves aliases cannot cross the
|
||||
// confirmation boundary before any transport call is made.
|
||||
var paramAliasNewDriveConfirmationCases = []struct {
|
||||
// paramAliasAITableDeleteDisableCompleteCommands contains complete invocations
|
||||
// for every AITable delete/disable command whose confirmation boundary is
|
||||
// reached by aliases introduced in the AITable expansion. These templates are
|
||||
// intentionally separate from paramAliasCompleteCommands: that map mirrors
|
||||
// the reviewed validation fixture one-for-one, while this matrix exhaustively
|
||||
// proves the safety boundary for generated aliases beyond the fixture sample.
|
||||
var paramAliasAITableDeleteDisableCompleteCommands = map[string][]string{
|
||||
"aitable +advperm-disable": {"aitable", "+advperm-disable", "--base-id", "base-1", "--yes"},
|
||||
"aitable +base-delete": {"aitable", "+base-delete", "--base-id", "base-1", "--yes"},
|
||||
"aitable +chart-delete": {"aitable", "+chart-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
|
||||
"aitable +dashboard-delete": {"aitable", "+dashboard-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
|
||||
"aitable +field-delete": {"aitable", "+field-delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
|
||||
"aitable +form-delete": {"aitable", "+form-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable +record-delete": {"aitable", "+record-delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"aitable +role-delete": {"aitable", "+role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
|
||||
"aitable +section-delete": {"aitable", "+section-delete", "--base-id", "base-1", "--section-id", "section-1", "--yes"},
|
||||
"aitable +table-delete": {"aitable", "+table-delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
|
||||
"aitable +view-delete": {"aitable", "+view-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable +workflow-disable": {"aitable", "+workflow-disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
|
||||
"aitable advperm disable": {"aitable", "advperm", "disable", "--base-id", "base-1", "--yes"},
|
||||
"aitable advperm role-delete": {"aitable", "advperm", "role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
|
||||
"aitable base delete": {"aitable", "base", "delete", "--base-id", "base-1", "--yes"},
|
||||
"aitable chart delete": {"aitable", "chart", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
|
||||
"aitable dashboard delete": {"aitable", "dashboard", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
|
||||
"aitable field delete": {"aitable", "field", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
|
||||
"aitable form delete": {"aitable", "form", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable form questions delete": {"aitable", "form", "questions", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
|
||||
"aitable record delete": {"aitable", "record", "delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"aitable table delete": {"aitable", "table", "delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
|
||||
"aitable view delete": {"aitable", "view", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable workflow disable": {"aitable", "workflow", "disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
|
||||
}
|
||||
|
||||
// paramAliasNewAITableDeleteDisableCases is the exhaustive set of alias
|
||||
// tuples newly introduced by this change on AITable delete/disable commands
|
||||
// that require confirmation. Every tuple must remain on both sides of the
|
||||
// confirmation gate: rejected with zero calls before --yes, and exactly
|
||||
// payload-equivalent to its canonical spelling after --yes.
|
||||
var paramAliasNewAITableDeleteDisableCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "aitable +advperm-disable", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +advperm-disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +base-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +base-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +chart-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +chart-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +dashboard-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +dashboard-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +field-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +field-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +field-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +form-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +form-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +form-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +record-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +record-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +record-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +role-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +role-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +section-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +section-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +table-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +table-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +table-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +view-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +view-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +view-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +workflow-disable", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +workflow-disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +workflow-disable", emitted: "flow-id", canonical: "workflow-id"},
|
||||
{command: "aitable advperm disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable advperm role-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable base delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable chart delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable dashboard delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable field delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable field delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable form delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable form delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable form questions delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable form questions delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable record delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable record delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable table delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable table delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable view delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable view delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable workflow disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable workflow disable", emitted: "flow-id", canonical: "workflow-id"},
|
||||
}
|
||||
|
||||
// paramAliasNewConfirmationCases selects newly reviewed aliases for commands
|
||||
// whose declared runtime safety requires confirmation. The full matrix below
|
||||
// proves all spellings preserve the confirmed payload; this smaller matrix
|
||||
// proves aliases cannot cross the confirmation boundary before any transport
|
||||
// call is made.
|
||||
var paramAliasNewConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "aitable workflow run", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable workflow run", emitted: "flow-id", canonical: "workflow-id"},
|
||||
{command: "aitable workflow run", emitted: "table", canonical: "table-id"},
|
||||
{command: "drive +delete", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
|
||||
@@ -402,7 +509,27 @@ var paramAliasNewDriveConfirmationCases = []struct {
|
||||
// That duplicated command construction was enough to push the pre-existing
|
||||
// macOS app suite beyond its package-level 10-minute timeout.
|
||||
var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("aitable +export-data", "export-format"): true, // shortcut-local export format keeps the final payload
|
||||
paramAliasPayloadCaseKey("aitable +find-record", "base-id"): true, // shortcut Base ID compatibility
|
||||
paramAliasPayloadCaseKey("aitable +find-record", "table-id"): true, // shortcut Table ID compatibility
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("aitable +record-share-links", "base-id"): true, // observed experiment Base ID spelling
|
||||
paramAliasPayloadCaseKey("aitable +record-share-links", "table-id"): true, // observed experiment Table ID spelling
|
||||
paramAliasPayloadCaseKey("aitable +workflow-list", "max-results"): true, // shortcut pagination-size alias
|
||||
paramAliasPayloadCaseKey("aitable attachment upload", "file-size"): true, // byte-size command override
|
||||
paramAliasPayloadCaseKey("aitable base list", "next-cursor"): true, // cursor concept alias
|
||||
paramAliasPayloadCaseKey("aitable base update", "description"): true, // plain description alias on a write command
|
||||
paramAliasPayloadCaseKey("aitable field search-options", "query"): true, // search keyword concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow get", "flow-id"): true, // workflow ID concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "base-token"): true, // Base ID concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "end-time"): true, // upper time-bound override
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "flow-id"): true, // workflow ID concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "page-index"): true, // zero-based page override
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "page-size"): true, // page-size concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "start-time"): true, // lower time-bound override
|
||||
paramAliasPayloadCaseKey("aitable workflow run", "base-token"): true, // Base ID concept alias on a confirmed write
|
||||
paramAliasPayloadCaseKey("aitable workflow run", "flow-id"): true, // workflow ID concept alias on a confirmed write
|
||||
paramAliasPayloadCaseKey("aitable workflow run", "table"): true, // Table ID concept alias on a confirmed write
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
@@ -426,7 +553,11 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
|
||||
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
|
||||
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
|
||||
paramAliasPayloadCaseKey("chat +messages-reply", "conversation-id"): true, // renamed conversation Primary keeps final reply payload
|
||||
paramAliasPayloadCaseKey("chat message send", "file-path"): true, // renamed local-file Primary reaches the same final payload
|
||||
paramAliasPayloadCaseKey("doc +doc-append", "text"): true, // shortcut content rename keeps append payload
|
||||
paramAliasPayloadCaseKey("doc block insert", "text"): true, // block write content compatibility alias
|
||||
paramAliasPayloadCaseKey("doc block update", "text"): true, // update uses the same typed compatibility path
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
@@ -629,13 +760,116 @@ func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalP
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewDriveConfirmationCases {
|
||||
func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmationAndPayload(t *testing.T) {
|
||||
coveredCommands := make(map[string]bool)
|
||||
reviewedAliases := make(map[string]string, len(paramAliasNewAITableDeleteDisableCases))
|
||||
for _, test := range paramAliasNewAITableDeleteDisableCases {
|
||||
test := test
|
||||
caseKey := paramAliasPayloadCaseKey(test.command, test.emitted)
|
||||
if previous, duplicate := reviewedAliases[caseKey]; duplicate {
|
||||
t.Fatalf("duplicate AITable delete/disable alias case %q: --%s and --%s", caseKey, previous, test.canonical)
|
||||
}
|
||||
reviewedAliases[caseKey] = test.canonical
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasAITableDeleteDisableCompleteCommands[test.command]
|
||||
if !ok {
|
||||
t.Fatal("reviewed AITable delete/disable alias has no complete safety template")
|
||||
}
|
||||
coveredCommands[test.command] = true
|
||||
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
|
||||
}
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("safety template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active || target != test.canonical {
|
||||
t.Fatalf("reviewed AITable alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
}
|
||||
|
||||
unconfirmedCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, unconfirmedErr := executeParamAliasPayloadE2E(t, unconfirmedCaller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed AITable alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(unconfirmedErr, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed AITable alias command error = %#v, want confirmation_required\nargs=%v", unconfirmedErr, unconfirmedArgs)
|
||||
}
|
||||
if len(unconfirmedCaller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed AITable alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, unconfirmedCaller.calls)
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("confirmed canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("confirmed canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
aliasCtx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("confirmed alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if aliasCtx == nil {
|
||||
t.Fatal("confirmed AITable alias command skipped PreParse")
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("confirmed final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for command := range paramAliasAITableDeleteDisableCompleteCommands {
|
||||
if !coveredCommands[command] {
|
||||
t.Errorf("AITable delete/disable safety template %q has no reviewed alias case", command)
|
||||
}
|
||||
}
|
||||
if len(coveredCommands) != len(paramAliasAITableDeleteDisableCompleteCommands) {
|
||||
t.Fatalf("AITable delete/disable safety coverage = %d commands, want %d", len(coveredCommands), len(paramAliasAITableDeleteDisableCompleteCommands))
|
||||
}
|
||||
|
||||
activeAliases := 0
|
||||
for command := range paramAliasAITableDeleteDisableCompleteCommands {
|
||||
entry, exists := cli.LookupParamAlias(command)
|
||||
if !exists {
|
||||
t.Errorf("AITable delete/disable safety command %q has no generated alias entry", command)
|
||||
continue
|
||||
}
|
||||
for emitted, canonical := range entry.Aliases {
|
||||
activeAliases++
|
||||
caseKey := paramAliasPayloadCaseKey(command, emitted)
|
||||
reviewedCanonical, reviewed := reviewedAliases[caseKey]
|
||||
if !reviewed {
|
||||
t.Errorf("active AITable delete/disable alias %q --%s -> --%s has no confirmation/payload case", command, emitted, canonical)
|
||||
continue
|
||||
}
|
||||
if reviewedCanonical != canonical {
|
||||
t.Errorf("reviewed AITable delete/disable alias %q --%s target = --%s, generated --%s", command, emitted, reviewedCanonical, canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
if activeAliases != len(reviewedAliases) {
|
||||
t.Fatalf("AITable delete/disable generated alias coverage = %d, want %d reviewed cases", activeAliases, len(reviewedAliases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
|
||||
t.Fatal("reviewed confirmation alias has no complete-command E2E template")
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
@@ -649,7 +883,7 @@ func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *te
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active || target != test.canonical {
|
||||
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
t.Fatalf("reviewed confirmation alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *t
|
||||
}
|
||||
|
||||
got := output.String() + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} {
|
||||
for _, want := range []string{"已下线", "chat message send --msg-type file --file"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat media upload migration output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -439,7 +439,7 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
|
||||
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
|
||||
}
|
||||
got = got + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -462,6 +462,53 @@ func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoDryRunPreviewsOnly(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
"--language", "zh-CN",
|
||||
"--calendar-id", "primary",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run error = %v\n%s", err, got)
|
||||
}
|
||||
for _, want := range []string{"get_event_share_info", "eventId", "EVT_001", "zh-CN", "primary"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("calendar event share-info dry-run output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoRequiresEventID(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("calendar event share-info without --id: expected error, got nil\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "\"executed\": true") {
|
||||
t.Fatalf("share-info without --id must not execute:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoOmitsOptionalArgs(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run with only --id error = %v\n%s", err, got)
|
||||
}
|
||||
if !strings.Contains(got, "\"eventId\"") {
|
||||
t.Fatalf("calendar event share-info dry-run output missing eventId:\n%s", got)
|
||||
}
|
||||
for _, unwanted := range []string{"\"calendarId\"", "\"language\""} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("calendar event share-info dry-run with only --id should not contain %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageCalendarAgendaFinalSchemaPreservesCompositeProperties(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
tool := snapshot.Tools["calendar.shortcut_agenda"]
|
||||
if tool == nil {
|
||||
t.Fatal("calendar.shortcut_agenda is missing from final Schema")
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for flag, want := range map[string]string{
|
||||
"start": "start",
|
||||
"end": "end",
|
||||
} {
|
||||
parameter := parameters[flag]
|
||||
if parameter == nil {
|
||||
t.Fatalf("calendar.shortcut_agenda --%s is missing from final Schema", flag)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Errorf("calendar.shortcut_agenda --%s property=%q, want %q", flag, got, want)
|
||||
}
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("calendar.shortcut_agenda interface_mode=%q, want composite", got)
|
||||
}
|
||||
result := schemaContractMap(tool["result"])
|
||||
dataSchema := schemaContractMap(result["data_schema"])
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
for _, field := range []string{"hasMore", "nextCursor"} {
|
||||
if _, exists := properties[field]; exists {
|
||||
t.Fatalf("calendar.shortcut_agenda Result data_schema leaked pagination field %q", field)
|
||||
}
|
||||
}
|
||||
if properties["complete"] == nil {
|
||||
t.Fatal("calendar.shortcut_agenda Result data_schema is missing complete")
|
||||
}
|
||||
pagination, ok := tool["pagination"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("calendar.shortcut_agenda pagination=%T, want object", tool["pagination"])
|
||||
}
|
||||
if got := schemaContractString(pagination["meta_path"]); got != "meta.pagination" {
|
||||
t.Fatalf("calendar.shortcut_agenda pagination meta_path=%q, want meta.pagination", got)
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,7 @@ func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command,
|
||||
func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"chat.send_personal_message",
|
||||
"chat.send_robot_message",
|
||||
"chat.reply_personal_message",
|
||||
)
|
||||
|
||||
@@ -418,6 +419,19 @@ func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
if _, exists := snapshot.Tools["chat.upload_conversation_file"]; exists {
|
||||
t.Fatal("downlined chat file upload must not be advertised in Schema")
|
||||
}
|
||||
|
||||
botReply := snapshot.Tools["chat.send_robot_message"]
|
||||
botParams := schemaContractMap(botReply["parameters"])
|
||||
if got := schemaContractString(botParams["reply"]["property"]); got != "referenceOpenMessageId" {
|
||||
t.Fatalf("bot --reply property = %q", got)
|
||||
}
|
||||
if got := schemaContractString(botParams["ref-sender"]["property"]); got != "srcMsgSendOpenDingTalkId" {
|
||||
t.Fatalf("bot --ref-sender property = %q", got)
|
||||
}
|
||||
if got, ok := botParams["title"]["required"].(bool); !ok || got {
|
||||
t.Fatalf("bot --title required = %#v, want false for conditional Markdown input", botParams["title"]["required"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, botReply, "require_together", []string{"reply", "ref-sender"})
|
||||
}
|
||||
|
||||
func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
|
||||
tests := []struct {
|
||||
cliPath string
|
||||
canonical string
|
||||
rpc string
|
||||
description string
|
||||
effect string
|
||||
resultType string
|
||||
resultFields map[string]string
|
||||
sensitivePaths []string
|
||||
}{
|
||||
{
|
||||
cliPath: "oa approval attachment download-url",
|
||||
canonical: "oa.get_attachment_download_url",
|
||||
rpc: "get_attachment_download_url",
|
||||
description: "获取审批附件下载授权并生成临时下载链接",
|
||||
effect: "read",
|
||||
resultType: "object",
|
||||
resultFields: map[string]string{
|
||||
"spaceId": "integer", "agentId": "integer", "downloadUri": "string",
|
||||
"class": "string", "fileId": "string",
|
||||
},
|
||||
sensitivePaths: []string{"downloadUri"},
|
||||
},
|
||||
{
|
||||
cliPath: "oa approval attachment authorize-download",
|
||||
canonical: "oa.auth_download_file",
|
||||
rpc: "auth_download_file",
|
||||
description: "批量授权当前用户下载指定的审批钉盘文件",
|
||||
effect: "write",
|
||||
resultType: "boolean",
|
||||
},
|
||||
{
|
||||
cliPath: "oa approval attachment authorize-preview",
|
||||
canonical: "oa.auth_preview_attachment",
|
||||
rpc: "auth_preview_attachment",
|
||||
description: "批量授权当前用户预览审批单中的附件",
|
||||
effect: "write",
|
||||
resultType: "object",
|
||||
resultFields: map[string]string{
|
||||
"spaceId": "integer", "agentId": "integer", "class": "string",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.rpc, func(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
command := exactCommandForTest(root, test.cliPath)
|
||||
if command == nil {
|
||||
t.Fatalf("executable command %q is missing", test.cliPath)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", test.cliPath, "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute delivery schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
|
||||
var tool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
|
||||
t.Fatalf("decode delivery schema leaf: %v", err)
|
||||
}
|
||||
if got := schemaContractString(tool["canonical_path"]); got != test.canonical {
|
||||
t.Fatalf("canonical_path = %q, want %q", got, test.canonical)
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != test.cliPath {
|
||||
t.Fatalf("primary_cli_path = %q, want %q", got, test.cliPath)
|
||||
}
|
||||
if got := schemaContractString(tool["description"]); !strings.HasPrefix(got, test.description) {
|
||||
t.Fatalf("description = %q, want prefix %q", got, test.description)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "mcp" {
|
||||
t.Fatalf("interface_mode = %q, want mcp", got)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
interfaceRef := schemaInterfaceObject(tool["interface_ref"])
|
||||
if got := schemaContractString(interfaceRef["product_id"]); got != "oa" {
|
||||
t.Fatalf("interface_ref.product_id = %q, want oa", got)
|
||||
}
|
||||
if got := schemaContractString(interfaceRef["rpc_name"]); got != test.rpc {
|
||||
t.Fatalf("interface_ref.rpc_name = %q, want %q", got, test.rpc)
|
||||
}
|
||||
if got := schemaContractString(tool["effect"]); got != test.effect {
|
||||
t.Fatalf("effect = %q, want %q", got, test.effect)
|
||||
}
|
||||
if got := schemaContractString(tool["risk"]); got != "low" {
|
||||
t.Fatalf("risk = %q, want low", got)
|
||||
}
|
||||
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
|
||||
t.Fatalf("confirmation = %q, want not_required", got)
|
||||
}
|
||||
if got := schemaContractString(tool["idempotency"]); got != "idempotent" {
|
||||
t.Fatalf("idempotency = %q, want idempotent", got)
|
||||
}
|
||||
fullResult := oaAttachmentResultContract(t, tool, test.resultType, test.resultFields, test.sensitivePaths)
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
root.SetArgs([]string{"schema", test.cliPath, "--compact", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute compact delivery schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var compactTool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &compactTool); err != nil {
|
||||
t.Fatalf("decode compact delivery schema leaf: %v", err)
|
||||
}
|
||||
compactResult, ok := compactTool["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("compact result = %#v, want object", compactTool["result"])
|
||||
}
|
||||
if !reflect.DeepEqual(compactResult, fullResult) {
|
||||
t.Fatalf("compact/full result projection differs\ncompact: %#v\nfull: %#v", compactResult, fullResult)
|
||||
}
|
||||
if problem := schemaHelpFlagCompletenessProblem(test.canonical, test.cliPath, command, tool); problem != "" {
|
||||
t.Fatal(problem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
|
||||
t.Helper()
|
||||
result, ok := tool["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("full result = %#v, want object", tool["result"])
|
||||
}
|
||||
if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("result.outcomes = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := schemaContractStringSlice(result["sensitive_paths"]); !reflect.DeepEqual(got, sensitivePaths) {
|
||||
t.Fatalf("result.sensitive_paths = %#v, want %#v", got, sensitivePaths)
|
||||
}
|
||||
dataSchema, ok := result["data_schema"].(map[string]any)
|
||||
if !ok || schemaContractString(dataSchema["type"]) != resultType {
|
||||
t.Fatalf("result.data_schema = %#v, want type %q", result["data_schema"], resultType)
|
||||
}
|
||||
properties, _ := dataSchema["properties"].(map[string]any)
|
||||
if len(properties) != len(fields) {
|
||||
t.Fatalf("result.data_schema.properties = %#v, want fields %#v", properties, fields)
|
||||
}
|
||||
for name, fieldType := range fields {
|
||||
property, ok := properties[name].(map[string]any)
|
||||
if !ok || schemaContractString(property["type"]) != fieldType {
|
||||
t.Fatalf("result.data_schema.properties.%s = %#v, want type %q", name, properties[name], fieldType)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 418
|
||||
publicShortcutCount = 434
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 420
|
||||
schemaPublishedShortcutCount = 437
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 418
|
||||
publiclyDeliveredShortcutCount = 434
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,242 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPrimaryParamMigrationKeepsConceptDirectionCommandScoped(t *testing.T) {
|
||||
concepts, err := LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
if got := concepts.ByConcept["content_text"].CanonicalHint; got != "text" {
|
||||
t.Fatalf("content_text canonical hint = %q, want unchanged global hint text", got)
|
||||
}
|
||||
if got := concepts.ByConcept["open_conversation_id"].CanonicalHint; got != "conversation-id" {
|
||||
t.Fatalf("open_conversation_id canonical hint = %q, want unchanged global hint conversation-id", got)
|
||||
}
|
||||
|
||||
content := concepts.ByConcept["content_text"]
|
||||
docNode := concepts.ByConcept["doc_node_id"]
|
||||
docAppend, problems := reduceLeafParamAliases(
|
||||
"doc +doc-append",
|
||||
realMap(
|
||||
realFlag{name: "content"},
|
||||
realFlag{name: "text", hidden: true},
|
||||
realFlag{name: "doc"},
|
||||
),
|
||||
[]Concept{content, docNode},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("doc +doc-append reduction problems = %v", problems)
|
||||
}
|
||||
assertPrimaryParamAlias(t, docAppend, "body", "content")
|
||||
assertPrimaryParamAlias(t, docAppend, "node", "doc")
|
||||
|
||||
insertOverride := primaryParamOverride(t, concepts, "doc block insert")
|
||||
docInsert, problems := reduceLeafParamAliases(
|
||||
"doc block insert",
|
||||
realMap(
|
||||
realFlag{name: "content"},
|
||||
realFlag{name: "text", hidden: true},
|
||||
realFlag{name: "parent-block"},
|
||||
realFlag{name: "ref-block"},
|
||||
),
|
||||
[]Concept{content},
|
||||
insertOverride,
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("doc block insert reduction problems = %v", problems)
|
||||
}
|
||||
assertPrimaryParamAlias(t, docInsert, "body", "content")
|
||||
|
||||
docUpdate, problems := reduceLeafParamAliases(
|
||||
"doc block update",
|
||||
realMap(realFlag{name: "content"}, realFlag{name: "text", hidden: true}),
|
||||
[]Concept{content},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("doc block update reduction problems = %v", problems)
|
||||
}
|
||||
assertPrimaryParamAlias(t, docUpdate, "body", "content")
|
||||
|
||||
replyOverride := primaryParamOverride(t, concepts, "chat +messages-reply")
|
||||
chatReply, problems := reduceLeafParamAliases(
|
||||
"chat +messages-reply",
|
||||
realMap(
|
||||
realFlag{name: "group"},
|
||||
realFlag{name: "conversation-id", hidden: true},
|
||||
realFlag{name: "ref-msg-id"},
|
||||
),
|
||||
[]Concept{concepts.ByConcept["open_conversation_id"]},
|
||||
replyOverride,
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("chat +messages-reply reduction problems = %v", problems)
|
||||
}
|
||||
for _, emitted := range []string{"chat", "chat-id", "open-conversation-id"} {
|
||||
assertPrimaryParamAlias(t, chatReply, emitted, "group")
|
||||
}
|
||||
assertPrimaryParamAlias(t, chatReply, "msg-id", "ref-msg-id")
|
||||
if chatReply.IsBlocked("group") {
|
||||
t.Fatal("chat +messages-reply canonical --group must not remain blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationUpdatesReviewedFixturesAndMappingKeys(t *testing.T) {
|
||||
concepts, err := LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
fixtures := []ParamFixtureCase{
|
||||
{Command: "doc +doc-append", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias"},
|
||||
{Command: "doc +doc-append", Emitted: "node", Expect: "doc", Via: "concept:doc_node_id"},
|
||||
{Command: "doc block insert", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 2},
|
||||
{Command: "doc block update", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 6},
|
||||
{Command: "chat message send", Emitted: "file-path", Expect: "file", Via: "native:reviewed-compatibility-alias"},
|
||||
{Command: "chat +messages-reply", Emitted: "conversation-id", Expect: "group", Via: "native:reviewed-compatibility-alias"},
|
||||
{Command: "chat +messages-reply", Emitted: "chat", Expect: "group", Via: "concept:open_conversation_id"},
|
||||
{Command: "chat +messages-reply", Emitted: "open-conversation-id", Expect: "group", Via: "concept:open_conversation_id"},
|
||||
}
|
||||
for _, want := range fixtures {
|
||||
found := false
|
||||
for _, got := range concepts.Fixture {
|
||||
if got == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("reviewed fixture missing %#v", want)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
oldKey string
|
||||
newKey string
|
||||
reason string
|
||||
}{
|
||||
{"chat.reply_personal_message --text", "chat.reply_personal_message --content", "serialized into the aggregate content JSON string"},
|
||||
{"chat.send_personal_message --file-path", "chat.send_personal_message --file", "local upload/preprocessing input"},
|
||||
{"chat.send_personal_message --text", "chat.send_personal_message --content", "serialized into the aggregate content payload"},
|
||||
{"doc.insert_document_block --text", "doc.insert_document_block --content", "aggregate convenience input used to build element"},
|
||||
{"doc.update_document_block --text", "doc.update_document_block --content", "aggregate convenience input used to build element"},
|
||||
{"todo.add_todo_attachment --file-path", "todo.add_todo_attachment --file", "local upload input used to construct attachmentList"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if _, exists := reviewedSchemaParameterMappingExclusions[test.oldKey]; exists {
|
||||
t.Errorf("stale mapping exclusion remains at %q", test.oldKey)
|
||||
}
|
||||
if got := reviewedSchemaParameterMappingExclusions[test.newKey]; got != test.reason {
|
||||
t.Errorf("mapping exclusion %q reason = %q, want %q", test.newKey, got, test.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationPreservesDeliverySchemaSignatures(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
primary string
|
||||
legacy string
|
||||
required bool
|
||||
cliRequired bool
|
||||
property string
|
||||
propertyFrom string
|
||||
format string
|
||||
requiredWhen string
|
||||
requiredWhenFrom string
|
||||
}{
|
||||
{path: "aisearch person", primary: "query", legacy: "keyword", required: true, property: "keyword", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat message reply", primary: "content", legacy: "text", required: true, cliRequired: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "chat message send", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "chat message send", primary: "file", legacy: "file-path", propertyFrom: "reviewed_mapping_exclusion", format: "file-path", requiredWhen: "msg-type is file or audio or video", requiredWhenFrom: "typed_parameter_metadata"},
|
||||
{path: "chat message send-by-webhook", primary: "content", legacy: "text", required: true, cliRequired: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +broadcast", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +dm", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +send-to-group", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-batch-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-send-by-webhook", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-reply", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-reply", primary: "group", legacy: "conversation-id", required: true, property: "conversationId", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "doc +doc-append", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "doc block insert", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "doc block update", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "todo task add-attachment", primary: "file", legacy: "file-path", required: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path+"/"+test.primary, func(t *testing.T) {
|
||||
leaf, err := queryDeliverySchemaPayload([]string{test.path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parameters := schemaMap(leaf["parameters"])
|
||||
parameter, exists := parameters[test.primary]
|
||||
if !exists {
|
||||
t.Fatalf("%s missing visible Primary --%s", test.path, test.primary)
|
||||
}
|
||||
if _, exists := parameters[test.legacy]; exists {
|
||||
t.Fatalf("%s unexpectedly publishes hidden legacy --%s", test.path, test.legacy)
|
||||
}
|
||||
if got := parameter["type"]; got != "string" {
|
||||
t.Errorf("%s --%s type = %#v, want string", test.path, test.primary, got)
|
||||
}
|
||||
if got := parameter["required"]; got != test.required {
|
||||
t.Errorf("%s --%s required = %#v, want %v", test.path, test.primary, got, test.required)
|
||||
}
|
||||
if got, _ := parameter["cli_required"].(bool); got != test.cliRequired {
|
||||
t.Errorf("%s --%s cli_required = %#v, want %v", test.path, test.primary, parameter["cli_required"], test.cliRequired)
|
||||
}
|
||||
if got, _ := parameter["property"].(string); got != test.property {
|
||||
t.Errorf("%s --%s property = %q, want %q", test.path, test.primary, got, test.property)
|
||||
}
|
||||
if got := parameter["interface_type"]; got != nil && got != "" {
|
||||
t.Errorf("%s --%s interface_type = %#v, want omitted/empty", test.path, test.primary, got)
|
||||
}
|
||||
if got, _ := parameter["format"].(string); got != test.format {
|
||||
t.Errorf("%s --%s format = %q, want %q", test.path, test.primary, got, test.format)
|
||||
}
|
||||
if got, _ := parameter["required_when"].(string); got != test.requiredWhen {
|
||||
t.Errorf("%s --%s required_when = %q, want %q", test.path, test.primary, got, test.requiredWhen)
|
||||
}
|
||||
|
||||
provenance := schemaMap(parameter["field_provenance"])
|
||||
propertyProvenance := provenance["property"]
|
||||
if got := propertyProvenance["source"]; got != test.propertyFrom {
|
||||
t.Errorf("%s --%s property source = %#v, want %s", test.path, test.primary, got, test.propertyFrom)
|
||||
}
|
||||
typeProvenance := provenance["type"]
|
||||
if got := typeProvenance["source"]; got != "cobra_flag_type" {
|
||||
t.Errorf("%s --%s type source = %#v, want cobra_flag_type", test.path, test.primary, got)
|
||||
}
|
||||
requiredWhenProvenance := provenance["required_when"]
|
||||
if got := requiredWhenProvenance["source"]; got != test.requiredWhenFrom {
|
||||
t.Errorf("%s --%s required_when source = %#v, want %s", test.path, test.primary, got, test.requiredWhenFrom)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func primaryParamOverride(t *testing.T, concepts ParamConcepts, path string) CommandOverride {
|
||||
t.Helper()
|
||||
for _, override := range concepts.Overrides {
|
||||
if override.CommandPath == path {
|
||||
return override
|
||||
}
|
||||
}
|
||||
t.Fatalf("command override %q not found", path)
|
||||
return CommandOverride{}
|
||||
}
|
||||
|
||||
func assertPrimaryParamAlias(t *testing.T, entry *ParamAliasEntry, emitted, want string) {
|
||||
t.Helper()
|
||||
if entry == nil {
|
||||
t.Fatalf("alias entry is nil; want %s -> %s", emitted, want)
|
||||
}
|
||||
if got, ok := entry.ResolveAlias(emitted); !ok || got != want {
|
||||
t.Fatalf("ResolveAlias(%q) = %q (ok=%v), want %q", emitted, got, ok, want)
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,9 @@ func init() {
|
||||
registerRequireOneOf("devdoc.search_open_platform_docs_rag", "query", "keyword")
|
||||
registerRequireOneOf("event.consume", "event_key", "subscribe-id")
|
||||
registerExclusiveOneOf("event.stop", "all", "subscribe_id")
|
||||
registerRequireOneOf("doc.insert_document_block", "text", "heading", "element")
|
||||
registerRequireOneOf("doc.insert_document_block", "content", "heading", "element")
|
||||
registerExclusiveOneOf("doc.update_document", "content", "content-file")
|
||||
registerRequireOneOf("doc.update_document_block", "text", "heading", "element")
|
||||
registerRequireOneOf("doc.update_document_block", "content", "heading", "element")
|
||||
registerRequireOneOf("pat.batch_grant", "scope", "product", "products", "domain", "domains", "recommend")
|
||||
registerRequireOneOf("mail.search_mail_users", "keyword", "employee-no")
|
||||
// --body is a hidden compatibility alias for the public --content flag.
|
||||
|
||||
@@ -269,20 +269,20 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --content": "serialized into the aggregate content JSON string",
|
||||
"chat.search_groups --cursor": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_groups --exclude-muted": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --exclude-muted is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_groups --limit": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_messages --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.search_messages --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.send_personal_message --contact-id": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --file-path": "local upload/preprocessing input",
|
||||
"chat.send_personal_message --file": "local upload/preprocessing input",
|
||||
"chat.send_personal_message --latitude": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --location-name": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --longitude": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --map-thumbnail-url": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --media-id": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --text": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --content": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --title": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --user": "resolves a userId to receiverOpenDingTalkId when possible and otherwise uses the unpinned receiverUserId compatibility property",
|
||||
"chat.set_group_member_mute_list --user": "conditional wrapper/alias of --users: userIds are remotely resolved to openDingTalkIds outside dry-run and otherwise may be sent as uids; openDingTalkId inputs are sent as openDingTalkIds, so one exact property would misdescribe execution",
|
||||
@@ -406,7 +406,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
|
||||
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --text": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
|
||||
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
|
||||
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -438,7 +438,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.update_document_block --fix-jsonml": "local JSONML normalization control",
|
||||
"doc.update_document_block --heading": "aggregate convenience input used to build element",
|
||||
"doc.update_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.update_document_block --text": "aggregate convenience input used to build element",
|
||||
"doc.update_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.version_list --cursor": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.version_list --limit": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.version_list --node": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -659,7 +659,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.write_image --file": "local upload input used to obtain resourceId/resourceUrl",
|
||||
"sheet.write_image --mime-type": "local upload metadata",
|
||||
"sheet.write_image --name": "local upload metadata",
|
||||
"todo.add_todo_attachment --file-path": "local upload input used to construct attachmentList",
|
||||
"todo.add_todo_attachment --file": "local upload input used to construct attachmentList",
|
||||
"todo.get_user_todos_in_current_org --query-all": "Local route selector: the default path calls get_user_todos_in_current_org, while --query-all switches to get_user_todos; it is not a property of the pinned default RPC.",
|
||||
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
|
||||
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
|
||||
|
||||
@@ -77,7 +77,7 @@ func cloneRuntimeSchemaStringMap(source map[string]string) map[string]string {
|
||||
|
||||
func init() {
|
||||
RegisterRuntimeSchemaParameterMetadata("aisearch.enterprise_person_search", RuntimeSchemaParameterMetadata{
|
||||
Required: []string{"keyword"},
|
||||
Required: []string{"query"},
|
||||
})
|
||||
RegisterRuntimeSchemaParameterMetadata("aitable.export_data", RuntimeSchemaParameterMetadata{
|
||||
RequiredWhen: map[string]string{
|
||||
@@ -146,8 +146,8 @@ func init() {
|
||||
})
|
||||
RegisterRuntimeSchemaParameterMetadata("chat.send_personal_message", RuntimeSchemaParameterMetadata{
|
||||
RequiredWhen: map[string]string{
|
||||
"media-id": "msg-type is image",
|
||||
"file-path": "msg-type is file or audio or video",
|
||||
"media-id": "msg-type is image",
|
||||
"file": "msg-type is file or audio or video",
|
||||
},
|
||||
Examples: map[string]string{"media-id": "@lADP_schema_smoke"},
|
||||
})
|
||||
|
||||
@@ -3,18 +3,21 @@ package helpers
|
||||
import (
|
||||
"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/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// boolPtr returns a pointer to v, for ParamDecl.Required declarations.
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// aisearchKeywordAliases 是 --keyword flag 的同义瞎猜兜底列表。
|
||||
// 模型可能写 --name / --q / --query / --text,这些都被识别为 keyword。
|
||||
var aisearchKeywordAliases = []string{"name", "q", "query", "text"}
|
||||
// 这些是原有的宽松兜底名,不属于本次 Primary 迁移;保持原生 hidden
|
||||
// 注册,不给它们新增 alias_of/origin 元数据。
|
||||
var aisearchPersonGuessFlags = []string{"name", "q", "text"}
|
||||
|
||||
// 历史解析优先级必须独立于新旧 Primary:双传时旧 --keyword 始终获胜。
|
||||
var aisearchQueryResolutionOrder = []string{"keyword", "name", "q", "query", "text"}
|
||||
|
||||
// flagValue 安全地读取 flag 值:先查 local,再查自身 PersistentFlags,
|
||||
// 再查 parents 的 PersistentFlags。比 cmd.Flags().GetString 更鲁棒,
|
||||
@@ -58,14 +61,11 @@ func aisearchFlagOrDefault(cmd *cobra.Command, primary, def string, aliases ...s
|
||||
return def
|
||||
}
|
||||
|
||||
// resolveAisearchKeyword 从命令的 flag 中解析 keyword:优先 --keyword,
|
||||
// 否则 fallback 到 aisearchKeywordAliases 中的任一同义 flag。
|
||||
// resolveAisearchKeyword 按历史顺序解析人员搜索词。虽然公开 Primary 已改为
|
||||
// --query,但兼容期内显式旧 --keyword 的值仍优先。
|
||||
func resolveAisearchKeyword(cmd *cobra.Command) string {
|
||||
if v := flagValue(cmd, "keyword"); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, alias := range aisearchKeywordAliases {
|
||||
if v := flagValue(cmd, alias); v != "" {
|
||||
for _, name := range aisearchQueryResolutionOrder {
|
||||
if v := flagValue(cmd, name); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,23 @@ func resolveAisearchKeyword(cmd *cobra.Command) string {
|
||||
}
|
||||
|
||||
func addAisearchPersonFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().StringP("keyword", "w", "", "搜索关键词 (必填,如人名、技能关键词等)")
|
||||
// Alias evidence must be emitted by corecmd.FlagSpec.Aliases. Build the pair
|
||||
// on a temporary declaration command so the legacy alias can retain its
|
||||
// historical -w shorthand without extending the global alias mechanism or
|
||||
// assigning that shorthand to the new --query Primary.
|
||||
declaration := &cobra.Command{Use: "aisearch-query-flags"}
|
||||
corecmd.RegisterFlags(declaration, []corecmd.FlagSpec{{
|
||||
Name: "query",
|
||||
Usage: "搜索关键词 (必填,如人名、技能关键词等)",
|
||||
Aliases: []string{"keyword"},
|
||||
}})
|
||||
queryFlag := declaration.Flags().Lookup("query")
|
||||
keywordFlag := declaration.Flags().Lookup("keyword")
|
||||
keywordFlag.Shorthand = "w"
|
||||
cmd.Flags().AddFlag(queryFlag)
|
||||
cmd.Flags().AddFlag(keywordFlag)
|
||||
cmd.Flags().StringP("dimension", "d", "all", "查询维度: all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,多个用逗号分隔")
|
||||
for _, alias := range aisearchKeywordAliases {
|
||||
for _, alias := range aisearchPersonGuessFlags {
|
||||
cmd.Flags().String(alias, "", "")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
@@ -98,8 +112,7 @@ func runAisearchPerson(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
keyword := resolveAisearchKeyword(cmd)
|
||||
if keyword == "" {
|
||||
// 复用原有报错文案("keyword is required")
|
||||
return validateRequiredFlags(cmd, "keyword")
|
||||
return validateRequiredFlags(cmd, "query")
|
||||
}
|
||||
dimensions := parseDimensions(flagValue(cmd, "dimension"))
|
||||
return callMCPTool("enterprise_person_search", map[string]any{
|
||||
@@ -188,8 +201,8 @@ func newAisearchCommand() *cobra.Command {
|
||||
Use: "aisearch",
|
||||
Short: "AI 搜问",
|
||||
Long: `AI 搜问:搜索企业人员信息、企业内部知识内容与企业内部行为记录。`,
|
||||
// 智能 root:模型常漏 person 子命令直接 dws aisearch --keyword xxx,
|
||||
// 检测到 keyword 就自动等价于 person;否则退回 group help。
|
||||
// 智能 root:模型常漏 person 子命令直接 dws aisearch --query xxx,
|
||||
// 检测到 query 就自动等价于 person;否则退回 group help。
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if resolveAisearchKeyword(cmd) != "" {
|
||||
return runAisearchPerson(cmd, args)
|
||||
@@ -199,9 +212,9 @@ func newAisearchCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
// root 和 person 各自定义同一组本地 flag,这样:
|
||||
// - dws aisearch --keyword xxx ← root 自己能解析
|
||||
// - dws aisearch person --keyword xxx ← person 本地 flag
|
||||
// - dws aisearch search --keyword xxx ← search 是 person 的 alias
|
||||
// - dws aisearch --query xxx ← root 自己能解析
|
||||
// - dws aisearch person --query xxx ← person 本地 flag
|
||||
// - dws aisearch search --query xxx ← search 是 person 的 alias
|
||||
// 不能放在 product PersistentFlags:否则 enterprise/behavior 会公开接受
|
||||
// 它们不拥有的 person-only dimension,破坏 Help ↔ Schema 完整性。
|
||||
addAisearchPersonFlags(root)
|
||||
@@ -219,7 +232,7 @@ func newAisearchCommand() *cobra.Command {
|
||||
"query", "people", "ask", "find", "lookup",
|
||||
"contact",
|
||||
},
|
||||
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --keyword xxx,
|
||||
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --query xxx,
|
||||
// 此时 "search" 会作为 positional arg 被忽略,不报错。
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Short: "搜索企业人员",
|
||||
@@ -237,13 +250,13 @@ func newAisearchCommand() *cobra.Command {
|
||||
jobNumber 工号
|
||||
|
||||
多个维度用逗号分隔。`,
|
||||
Example: ` dws aisearch person --keyword "张三" --dimension department
|
||||
dws aisearch person --keyword "产品部" --dimension department
|
||||
dws aisearch person --keyword "五道" --dimension supervisor
|
||||
dws aisearch person --keyword "AI搜问" --dimension duty
|
||||
dws aisearch person --keyword "李四" --dimension name,department
|
||||
dws aisearch person --keyword "13800138000" --dimension phone
|
||||
dws aisearch person --keyword "W12345" --dimension jobNumber`,
|
||||
Example: ` dws aisearch person --query "张三" --dimension department
|
||||
dws aisearch person --query "产品部" --dimension department
|
||||
dws aisearch person --query "五道" --dimension supervisor
|
||||
dws aisearch person --query "AI搜问" --dimension duty
|
||||
dws aisearch person --query "李四" --dimension name,department
|
||||
dws aisearch person --query "13800138000" --dimension phone
|
||||
dws aisearch person --query "W12345" --dimension jobNumber`,
|
||||
RunE: runAisearchPerson,
|
||||
}
|
||||
DeclareLeafMetadata(personCmd, LeafSpec{
|
||||
@@ -277,10 +290,13 @@ func newAisearchCommand() *cobra.Command {
|
||||
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws aisearch person --keyword \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json",
|
||||
"dws aisearch person --query \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --query \"五道\" --dimension supervisor --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "query", Property: "keyword", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
addAisearchPersonFlags(personCmd)
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
)
|
||||
|
||||
func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
|
||||
root := newAisearchCommand()
|
||||
@@ -12,12 +17,34 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"keyword", "dimension"} {
|
||||
for _, name := range []string{"query", "dimension"} {
|
||||
flag := person.Flags().Lookup(name)
|
||||
if flag == nil || flag.Hidden {
|
||||
t.Fatalf("person --%s = %#v, want visible local flag", name, flag)
|
||||
}
|
||||
}
|
||||
if person.Flags().Lookup("query").Shorthand != "" {
|
||||
t.Fatal("new Primary --query must not acquire a shorthand")
|
||||
}
|
||||
keyword := person.Flags().Lookup("keyword")
|
||||
if keyword == nil || !keyword.Hidden || keyword.Shorthand != "w" {
|
||||
t.Fatalf("person --keyword = %#v, want hidden legacy flag with -w", keyword)
|
||||
}
|
||||
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "query" {
|
||||
t.Fatalf("person --keyword alias_of = %#v, want query", got)
|
||||
}
|
||||
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("person --keyword alias_origin = %#v, want %q", got, runtimeannotate.FlagAliasOriginCorecmdV1)
|
||||
}
|
||||
for _, name := range []string{"name", "q", "text"} {
|
||||
flag := person.Flags().Lookup(name)
|
||||
if flag == nil || !flag.Hidden {
|
||||
t.Fatalf("person --%s = %#v, want preserved hidden guess flag", name, flag)
|
||||
}
|
||||
if got := flag.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 0 {
|
||||
t.Fatalf("person --%s unexpectedly gained alias_of metadata: %#v", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{"enterprise", "behavior"} {
|
||||
cmd, _, err := root.Find([]string{path})
|
||||
@@ -35,3 +62,38 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAisearchPersonQueryPrimaryCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "root new", args: []string{"--query", "new"}, want: "new"},
|
||||
{name: "root old", args: []string{"--keyword", "old"}, want: "old"},
|
||||
{name: "root old shorthand", args: []string{"-w", "old-short"}, want: "old-short"},
|
||||
{name: "root both old wins", args: []string{"--query", "new", "--keyword", "old"}, want: "old"},
|
||||
{name: "person new", args: []string{"person", "--query", "new"}, want: "new"},
|
||||
{name: "person old", args: []string{"person", "--keyword", "old"}, want: "old"},
|
||||
{name: "person old shorthand", args: []string{"person", "-w", "old-short"}, want: "old-short"},
|
||||
{name: "person both old wins", args: []string{"person", "--query", "new", "--keyword", "old"}, want: "old"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executeFilterCoverage(t, newAisearchCommand(), tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "enterprise_person_search" || caller.args["keyword"] != tc.want {
|
||||
t.Fatalf("call = %s %#v, want backend keyword %q", caller.tool, caller.args, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
err := executeFilterCoverage(t, newAisearchCommand(), "person")
|
||||
if err == nil || !strings.Contains(err.Error(), "--query") || strings.Contains(err.Error(), "--keyword") {
|
||||
t.Fatalf("missing query error = %v, want only new Primary", err)
|
||||
}
|
||||
}
|
||||
|
||||
+308
-2
@@ -520,6 +520,102 @@ func normalizeFilters(parsed any) any {
|
||||
}
|
||||
}
|
||||
|
||||
type aitableStatsItem struct {
|
||||
FieldID string `json:"fieldId"`
|
||||
StatsType string `json:"statsType"`
|
||||
}
|
||||
|
||||
func aitableStatsValidationf(format string, args ...any) error {
|
||||
return apperrors.NewValidation(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func parseAitableStatsItems(raw string, uppercase, uniqueFields bool, maxItems int) ([]map[string]any, error) {
|
||||
var items []aitableStatsItem
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil, aitableStatsValidationf("--stats 必须是 JSON 数组: %v", err)
|
||||
}
|
||||
var strictItems []aitableStatsItem
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&strictItems); err != nil {
|
||||
return nil, aitableStatsValidationf("--stats 包含不支持的字段: %v", err)
|
||||
}
|
||||
items = strictItems
|
||||
if len(items) == 0 {
|
||||
return nil, aitableStatsValidationf("--stats 至少需要一个统计项")
|
||||
}
|
||||
if maxItems > 0 && len(items) > maxItems {
|
||||
return nil, aitableStatsValidationf("--stats 单次最多支持 %d 个统计项,got %d", maxItems, len(items))
|
||||
}
|
||||
|
||||
seenFields := make(map[string]struct{}, len(items))
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
item.FieldID = strings.TrimSpace(item.FieldID)
|
||||
item.StatsType = strings.TrimSpace(item.StatsType)
|
||||
if item.FieldID == "" || item.StatsType == "" {
|
||||
return nil, aitableStatsValidationf("--stats[%d] 的 fieldId 和 statsType 均不能为空", index)
|
||||
}
|
||||
if uniqueFields {
|
||||
if _, exists := seenFields[item.FieldID]; exists {
|
||||
return nil, aitableStatsValidationf("--stats 中 fieldId %q 重复;query_records_stats 要求同一字段的多个统计类型拆成多次调用", item.FieldID)
|
||||
}
|
||||
seenFields[item.FieldID] = struct{}{}
|
||||
}
|
||||
if uppercase && item.StatsType != strings.ToUpper(item.StatsType) {
|
||||
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用大写枚举值,got %q", index, item.StatsType)
|
||||
}
|
||||
if !uppercase && item.StatsType != strings.ToLower(item.StatsType) {
|
||||
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用小写值,got %q", index, item.StatsType)
|
||||
}
|
||||
out = append(out, map[string]any{"fieldId": item.FieldID, "statsType": item.StatsType})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseAitableObjectFlag(name, raw string) (map[string]any, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value map[string]any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象: %v", name, err)
|
||||
}
|
||||
if value == nil {
|
||||
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象,不能是 null", name)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, aitableStatsValidationf("--%s 只能包含一个 JSON 对象", name)
|
||||
}
|
||||
return nil, aitableStatsValidationf("--%s 包含无效的尾随内容: %v", name, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateAitableStatsFilters(value map[string]any, rawJSON string) error {
|
||||
if err := validateFiltersStructure(value, rawJSON); err != nil {
|
||||
return aitableStatsValidationf("%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableArrayDSL(name, raw string) error {
|
||||
var value []any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return aitableStatsValidationf("--%s 必须是 JSON 数组字符串: %v", name, err)
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return aitableStatsValidationf("--%s 至少需要一个条目", name)
|
||||
}
|
||||
for index, item := range value {
|
||||
if _, ok := item.(map[string]any); !ok {
|
||||
return aitableStatsValidationf("--%s[%d] 必须是 JSON 对象,got %T", name, index, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeViewConfigFilter 将 view config 中的 filter 字段规范化为服务端要求的数组格式。
|
||||
// 服务端 POJO 要求 config.filter 为 []FilterRule(JSON array)。
|
||||
// 常见错误格式:
|
||||
@@ -1098,7 +1194,7 @@ func newAitableCommand() *cobra.Command {
|
||||
dws aitable base [list|search|get|get-primary-doc-id|create|update|delete|copy] Base 管理
|
||||
dws aitable table [get|create|update|delete] 数据表管理
|
||||
dws aitable field [get|create|update|delete|search-options] 字段管理
|
||||
dws aitable record [query|create|update|delete] 记录管理
|
||||
dws aitable record [query|stats|group-stats|create|update|delete] 记录管理
|
||||
dws aitable view [get|create|update|delete] 视图管理
|
||||
dws aitable form [list|delete|update] 表单管理
|
||||
dws aitable form field [list|update|hide] 表单字段管理
|
||||
@@ -2245,6 +2341,199 @@ newFieldName、config、aiConfig 至少传入一项。
|
||||
},
|
||||
})
|
||||
|
||||
recordStatsCmd := &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "整表或过滤后的字段聚合统计",
|
||||
Long: `对单张数据表执行不分组的服务端聚合,底层调用 query_records_stats。
|
||||
适用于记录计数、求和、平均值、最大值、最小值、中位数、去重数、完整率等标量统计。
|
||||
|
||||
--stats 是 JSON 数组,单次最多 20 项;每项必须包含 fieldId 和大写 statsType。
|
||||
同一个 fieldId 不能在一次请求中重复,如需对同一字段计算多个指标,请拆成多次调用。
|
||||
常用 statsType:COUNT、COUNT_COLUMN、SUM、AVG、MAX、MIN、MEDIAN、STANDARD_DEVIATION、RANGE、
|
||||
DISTINCT、DISTINCT_RATIO、EXISTS、UN_EXISTS、EXIST_RATIO、UN_EXIST_RATIO、CHECKED、UN_CHECKED、
|
||||
CHECKED_RATIO、UN_CHECKED_RATIO、LATEST_DATE、EARLIEST_DATE、DATE_RANGE、DATE_RANGE_MONTH。
|
||||
|
||||
需要统计全部匹配记录时不要传 --limit;limit 会改变参与统计的记录范围。
|
||||
lt/gt/lte/gte 的过滤值必须使用 JSON 数字;单选/多选过滤建议使用 field get 返回的选项 ID。`,
|
||||
Example: ` dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldAmount","statsType":"SUM"}]'
|
||||
dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --filters '{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}' --stats '[{"fieldId":"fldTitle","statsType":"COUNT"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), true, true, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"stats": stats,
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
|
||||
filters, err := parseAitableObjectFlag("filters", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableStatsFilters(filters, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["filters"] = normalizeFilters(filters)
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
|
||||
if err := validateAitableArrayDSL("sort", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
// query_records_stats 的 MCP Schema 将 sort 定义为 JSON 数组编码后的字符串,
|
||||
// 与 query_records 使用的数组类型不同。
|
||||
toolArgs["sort"] = raw
|
||||
}
|
||||
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
|
||||
if value <= 0 {
|
||||
return aitableStatsValidationf("--limit 必须大于 0;统计全部匹配记录时请省略该参数")
|
||||
}
|
||||
toolArgs["limit"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("keyword"); value != "" {
|
||||
toolArgs["keyword"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("search-field-ids"); value != "" {
|
||||
toolArgs["searchFieldIds"] = parseCSVValues(value)
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
|
||||
toolArgs["dataVersion"] = value
|
||||
}
|
||||
return callAitableTool("query_records_stats", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(recordStatsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "query_records_stats",
|
||||
CanonicalPath: "aitable.query_records_stats",
|
||||
CLIPath: "aitable record stats",
|
||||
PrimaryCLIPath: "aitable record stats",
|
||||
},
|
||||
Description: "对整表或过滤后的记录执行不分组字段聚合。",
|
||||
Interface: aitableMCPInterface("query_records_stats"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对整表或过滤后的记录执行不分组字段聚合。",
|
||||
UseWhen: []string{"需要记录总数、SUM/AVG/MAX/MIN、中位数、去重数、完整率等标量统计时,优先服务端聚合"},
|
||||
AvoidWhen: []string{"需要按字段分组或使用 query_stats 高级聚合时用 record group-stats;需要行级明细时用 record query"},
|
||||
Examples: []string{`dws aitable record stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"COUNT"}]'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
|
||||
{Name: "filters", Property: "filters", InterfaceType: "object"},
|
||||
{Name: "search-field-ids", Property: "searchFieldIds", InterfaceType: "array"},
|
||||
{Name: "data-version", Property: "dataVersion"},
|
||||
},
|
||||
Result: aitableRecordsStatsResultSpec(),
|
||||
},
|
||||
})
|
||||
|
||||
recordGroupStatsCmd := &cobra.Command{
|
||||
Use: "group-stats",
|
||||
Short: "分组、去重及高级聚合统计",
|
||||
Long: `对单张数据表执行分组或高级服务端聚合,底层调用 query_stats,最多返回 1000 个分组。
|
||||
适用于按一个或多个字段分组、条件唯一实体计数,以及 distinct、distinct_ratio 等高级统计。
|
||||
|
||||
--stats 是 JSON 数组,每项包含 fieldId 和小写 statsType。基础类型为 sum、avg、count、max、min;
|
||||
部分后端也支持 median、distinct、distinct_ratio 等高级类型,不支持时服务端会返回明确错误。
|
||||
--group 是 JSON 数组编码后的字符串,不分组的 DISTINCT 标量统计可以省略。
|
||||
不要依赖服务端 limit 选择 Top N;应在不超过 1000 个分组时获取完整结果后再排序。`,
|
||||
Example: ` dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --group '[{"fieldId":"fldCategory","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]' --stats '[{"fieldId":"fldAmount","statsType":"sum"}]'
|
||||
dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldStore","statsType":"distinct"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), false, false, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"stats": stats,
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
|
||||
filters, err := parseAitableObjectFlag("filters", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableStatsFilters(filters, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["filters"] = normalizeFilters(filters)
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("group"); raw != "" {
|
||||
if err := validateAitableArrayDSL("group", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["group"] = raw
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
|
||||
if err := validateAitableArrayDSL("sort", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["sortDsl"] = raw
|
||||
}
|
||||
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
|
||||
if value < 1 || value > 1000 {
|
||||
return aitableStatsValidationf("--limit 必须在 [1, 1000] 范围内,got %d", value)
|
||||
}
|
||||
toolArgs["limit"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
|
||||
toolArgs["dataVersion"] = value
|
||||
}
|
||||
return callAitableTool("query_stats", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(recordGroupStatsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "query_stats",
|
||||
CanonicalPath: "aitable.query_stats",
|
||||
CLIPath: "aitable record group-stats",
|
||||
PrimaryCLIPath: "aitable record group-stats",
|
||||
},
|
||||
Description: "按字段分组,或执行去重等高级聚合统计。",
|
||||
Interface: aitableMCPInterface("query_stats"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按字段分组,或执行去重等高级聚合统计。",
|
||||
UseWhen: []string{"需要各分类/实体的分组统计,或对满足条件的门店、客户、商品等执行 DISTINCT 唯一计数时"},
|
||||
AvoidWhen: []string{"普通不分组 COUNT/SUM/AVG/MAX/MIN 优先用 record stats;原始记录和 Top N 明细用 record query"},
|
||||
Examples: []string{`dws aitable record group-stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"distinct"}]'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
|
||||
{Name: "filters", Property: "filters", InterfaceType: "object"},
|
||||
{Name: "group", Property: "group"},
|
||||
{Name: "sort", Property: "sortDsl"},
|
||||
{Name: "data-version", Property: "dataVersion"},
|
||||
},
|
||||
Result: aitableGroupedStatsResultSpec(),
|
||||
},
|
||||
})
|
||||
|
||||
recordCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "新增记录",
|
||||
@@ -7230,6 +7519,23 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
|
||||
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
|
||||
_ = recordQueryCmd.Flags().MarkHidden("view-id")
|
||||
recordStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
|
||||
recordStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
|
||||
recordStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"SUM"}];statsType 必须大写,最多 20 项且 fieldId 不得重复`)
|
||||
recordStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
|
||||
recordStatsCmd.Flags().String("sort", "", `参与统计记录的排序 DSL(JSON 数组字符串),例如 [{"fieldId":"fldDate","direction":"ASC"}]`)
|
||||
recordStatsCmd.Flags().Int("limit", 0, "参与统计的最大记录数;省略表示统计全部匹配记录")
|
||||
recordStatsCmd.Flags().String("keyword", "", "全文关键词,仅匹配的记录参与统计")
|
||||
recordStatsCmd.Flags().String("search-field-ids", "", "关键词搜索字段 ID 列表,逗号分隔;仅 --keyword 非空时生效")
|
||||
recordStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
|
||||
recordGroupStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
|
||||
recordGroupStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
|
||||
recordGroupStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"sum"}];statsType 必须小写`)
|
||||
recordGroupStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
|
||||
recordGroupStatsCmd.Flags().String("group", "", `分组字段 DSL(JSON 数组字符串);不分组的 distinct 统计可省略`)
|
||||
recordGroupStatsCmd.Flags().String("sort", "", "统计结果排序 DSL(JSON 数组字符串),映射到 MCP sortDsl")
|
||||
recordGroupStatsCmd.Flags().Int("limit", 0, "返回的分组结果数,范围 1-1000;省略表示不额外限制")
|
||||
recordGroupStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
|
||||
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
|
||||
recordCreateCmd.Flags().String("table-id", "", "Table ID,可通过 base get 获取 (必填)")
|
||||
recordCreateCmd.Flags().String("records", "", "待创建的记录列表 JSON 数组,单次最多 100 条 (必填)")
|
||||
@@ -7294,7 +7600,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
recordPrimaryDocCreateCmd.Flags().String("record-id", "", "目标 Record ID (必填)")
|
||||
|
||||
recordCmd.AddCommand(
|
||||
recordQueryCmd, recordCreateCmd,
|
||||
recordQueryCmd, recordStatsCmd, recordGroupStatsCmd, recordCreateCmd,
|
||||
recordUpdateCmd, recordDeleteCmd,
|
||||
recordBatchUpdateCmd,
|
||||
recordHistoryListCmd,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
@@ -72,3 +74,102 @@ func aitableCompositeInterface(reason string) *contract.InterfaceSpec {
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func aitableRecordsStatsResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"不分组的字段聚合结果",
|
||||
"properties":{
|
||||
"results":{
|
||||
"type":"array",
|
||||
"description":"按数据版本返回的聚合结果批次",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"dataVersion":{"description":"参与统计的数据版本"},
|
||||
"deltaVersion":{"description":"聚合结果的增量版本"},
|
||||
"results":{
|
||||
"type":"array",
|
||||
"description":"请求中各统计项的结果",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"fieldId":{"type":"string","description":"被统计的字段 ID"},
|
||||
"statsType":{"type":"string","description":"实际执行的统计类型"},
|
||||
"value":{"description":"统计值;具体 JSON 类型取决于统计类型和字段类型"}
|
||||
},
|
||||
"required":["fieldId","statsType","value"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["results"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["results"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func aitableGroupedStatsResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"分组或高级字段聚合结果",
|
||||
"properties":{
|
||||
"dataVersion":{"description":"参与统计的数据版本"},
|
||||
"results":{
|
||||
"type":"array",
|
||||
"description":"每个分组一条结果;未分组时通常只有一条",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"groupKeys":{
|
||||
"type":"array",
|
||||
"description":"当前结果的分组键",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"fieldId":{"type":"string","description":"分组字段 ID"},
|
||||
"value":{"description":"分组值;编码取决于字段类型"},
|
||||
"recordCount":{"type":"integer","description":"该分组包含的记录数"}
|
||||
},
|
||||
"additionalProperties":true
|
||||
}
|
||||
},
|
||||
"fieldStatsMap":{
|
||||
"type":"object",
|
||||
"description":"按字段 ID 索引的聚合值",
|
||||
"additionalProperties":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"action":{"type":"string","description":"实际执行的统计动作"},
|
||||
"value":{"description":"统计值;具体 JSON 类型取决于统计动作和字段类型"}
|
||||
},
|
||||
"required":["action","value"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["fieldStatsMap"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["results"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func runAitableStatsCLI(t *testing.T, args ...string) (*aitableTestCaller, error) {
|
||||
t.Helper()
|
||||
caller := &aitableTestCaller{}
|
||||
installAitableDeps(t, caller)
|
||||
command := newAitableCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(args)
|
||||
return caller, command.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableRecordsStatsForwardsValidatedContract(t *testing.T) {
|
||||
caller, err := runAitableStatsCLI(t,
|
||||
"record", "stats",
|
||||
"--base-id", "base-stats",
|
||||
"--table-id", "table-stats",
|
||||
"--stats", `[{"fieldId":"fldCount","statsType":"COUNT"},{"fieldId":"fldAmount","statsType":"SUM"}]`,
|
||||
"--filters", `{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}`,
|
||||
"--sort", `[{"fieldId":"fldAmount","direction":"DESC"}]`,
|
||||
"--limit", "500",
|
||||
"--keyword", "华东",
|
||||
"--search-field-ids", "fldName,fldRegion",
|
||||
"--data-version", "42",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("record stats returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "aitable" || call.tool != "query_records_stats" {
|
||||
t.Fatalf("tool = %s/%s, want aitable/query_records_stats", call.server, call.tool)
|
||||
}
|
||||
wantStats := []map[string]any{
|
||||
{"fieldId": "fldCount", "statsType": "COUNT"},
|
||||
{"fieldId": "fldAmount", "statsType": "SUM"},
|
||||
}
|
||||
if !reflect.DeepEqual(call.args["stats"], wantStats) {
|
||||
t.Fatalf("stats = %#v, want %#v", call.args["stats"], wantStats)
|
||||
}
|
||||
filters := call.args["filters"].(map[string]any)
|
||||
condition := filters["operands"].([]any)[0].(map[string]any)
|
||||
if got := condition["operands"].([]any)[1]; got != json.Number("0") {
|
||||
t.Fatalf("numeric filter value = %#v (%T), want json.Number(0)", got, got)
|
||||
}
|
||||
if call.args["sort"] != `[{"fieldId":"fldAmount","direction":"DESC"}]` ||
|
||||
call.args["limit"] != 500 || call.args["keyword"] != "华东" || call.args["dataVersion"] != "42" {
|
||||
t.Fatalf("forwarded args = %#v", call.args)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args["searchFieldIds"], []string{"fldName", "fldRegion"}) {
|
||||
t.Fatalf("searchFieldIds = %#v", call.args["searchFieldIds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableGroupStatsForwardsStringDSL(t *testing.T) {
|
||||
group := `[{"fieldId":"fldRegion","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]`
|
||||
sortDSL := `[{"fieldId":"fldAmount","direction":"DESC"}]`
|
||||
caller, err := runAitableStatsCLI(t,
|
||||
"record", "group-stats",
|
||||
"--base-id", "base-stats",
|
||||
"--table-id", "table-stats",
|
||||
"--stats", `[{"fieldId":"fldStore","statsType":"distinct"},{"fieldId":"fldAmount","statsType":"avg"}]`,
|
||||
"--filters", `{"operator":"OR","operands":[{"operator":"not_before","operands":["fldDate","2026-01-01"]}]}`,
|
||||
"--group", group,
|
||||
"--sort", sortDSL,
|
||||
"--limit", "1000",
|
||||
"--data-version", "43",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("record group-stats returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "aitable" || call.tool != "query_stats" {
|
||||
t.Fatalf("tool = %s/%s, want aitable/query_stats", call.server, call.tool)
|
||||
}
|
||||
if call.args["group"] != group || call.args["sortDsl"] != sortDSL || call.args["limit"] != 1000 || call.args["dataVersion"] != "43" {
|
||||
t.Fatalf("forwarded args = %#v", call.args)
|
||||
}
|
||||
stats := call.args["stats"].([]map[string]any)
|
||||
if len(stats) != 2 || stats[0]["statsType"] != "distinct" || stats[1]["statsType"] != "avg" {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableStatsRejectsInvalidInputsBeforeCallingMCP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "records stats require uppercase",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`},
|
||||
wantErr: "大写",
|
||||
},
|
||||
{
|
||||
name: "records stats require at least one item",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[]`},
|
||||
wantErr: "至少需要一个统计项",
|
||||
},
|
||||
{
|
||||
name: "records stats enforce item cap",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[
|
||||
{"fieldId":"f01","statsType":"COUNT"},{"fieldId":"f02","statsType":"COUNT"},
|
||||
{"fieldId":"f03","statsType":"COUNT"},{"fieldId":"f04","statsType":"COUNT"},
|
||||
{"fieldId":"f05","statsType":"COUNT"},{"fieldId":"f06","statsType":"COUNT"},
|
||||
{"fieldId":"f07","statsType":"COUNT"},{"fieldId":"f08","statsType":"COUNT"},
|
||||
{"fieldId":"f09","statsType":"COUNT"},{"fieldId":"f10","statsType":"COUNT"},
|
||||
{"fieldId":"f11","statsType":"COUNT"},{"fieldId":"f12","statsType":"COUNT"},
|
||||
{"fieldId":"f13","statsType":"COUNT"},{"fieldId":"f14","statsType":"COUNT"},
|
||||
{"fieldId":"f15","statsType":"COUNT"},{"fieldId":"f16","statsType":"COUNT"},
|
||||
{"fieldId":"f17","statsType":"COUNT"},{"fieldId":"f18","statsType":"COUNT"},
|
||||
{"fieldId":"f19","statsType":"COUNT"},{"fieldId":"f20","statsType":"COUNT"},
|
||||
{"fieldId":"f21","statsType":"COUNT"}
|
||||
]`},
|
||||
wantErr: "单次最多支持 20 个统计项",
|
||||
},
|
||||
{
|
||||
name: "records stats require item fields",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":" ","statsType":"COUNT"}]`},
|
||||
wantErr: "均不能为空",
|
||||
},
|
||||
{
|
||||
name: "records stats reject unknown item fields",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT","unknownKey":123}]`},
|
||||
wantErr: "不支持的字段",
|
||||
},
|
||||
{
|
||||
name: "records stats reject duplicate field",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"},{"fieldId":"f","statsType":"SUM"}]`},
|
||||
wantErr: "重复",
|
||||
},
|
||||
{
|
||||
name: "group stats require lowercase",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`},
|
||||
wantErr: "小写",
|
||||
},
|
||||
{
|
||||
name: "group must be array DSL",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--group", `{}`},
|
||||
wantErr: "JSON 数组",
|
||||
},
|
||||
{
|
||||
name: "group limit capped",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--limit", "1001"},
|
||||
wantErr: "[1, 1000]",
|
||||
},
|
||||
{
|
||||
name: "filters require logical root",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"eq","operands":[]}`},
|
||||
wantErr: `root "operator" must be "and" or "or"`,
|
||||
},
|
||||
{
|
||||
name: "records filters reject malformed JSON",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{`},
|
||||
wantErr: "必须是 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "records filters reject null",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `null`},
|
||||
wantErr: "不能是 null",
|
||||
},
|
||||
{
|
||||
name: "records filters reject a second JSON value",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} {}`},
|
||||
wantErr: "只能包含一个 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "records filters reject invalid trailing content",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} trailing`},
|
||||
wantErr: "无效的尾随内容",
|
||||
},
|
||||
{
|
||||
name: "records filters require operand array",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":{}}`},
|
||||
wantErr: `"operands" must be an array`,
|
||||
},
|
||||
{
|
||||
name: "records filters reject silently unsupported operators",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
|
||||
wantErr: `unsupported filter operator "date_between"`,
|
||||
},
|
||||
{
|
||||
name: "records sort requires an item",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[]`},
|
||||
wantErr: "至少需要一个条目",
|
||||
},
|
||||
{
|
||||
name: "records sort items must be objects",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[1]`},
|
||||
wantErr: "必须是 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "records limit must be positive",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--limit", "0"},
|
||||
wantErr: "必须大于 0",
|
||||
},
|
||||
{
|
||||
name: "group filters reject malformed JSON",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{`},
|
||||
wantErr: "必须是 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "group filters require logical root",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"eq","operands":[]}`},
|
||||
wantErr: `root "operator" must be "and" or "or"`,
|
||||
},
|
||||
{
|
||||
name: "group filters reject silently unsupported operators",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
|
||||
wantErr: `unsupported filter operator "date_between"`,
|
||||
},
|
||||
{
|
||||
name: "group sort rejects invalid DSL",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--sort", `{}`},
|
||||
wantErr: "JSON 数组",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller, err := runAitableStatsCLI(t, test.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error = %#v, want structured validation error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableStatsPublishInterfaceAndResultContracts(t *testing.T) {
|
||||
root := newAitableCommand()
|
||||
tests := []struct {
|
||||
path string
|
||||
rpc string
|
||||
}{
|
||||
{path: "aitable record stats", rpc: "query_records_stats"},
|
||||
{path: "aitable record group-stats", rpc: "query_stats"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
leaf := findCLIPath(root, test.path)
|
||||
if leaf == nil {
|
||||
t.Fatalf("missing leaf %q", test.path)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !ok || final.Interface == nil || final.Interface.Ref == nil || final.Interface.Ref.RPCName != test.rpc {
|
||||
t.Fatalf("%s interface = %#v", test.path, final.Interface)
|
||||
}
|
||||
if final.Result == nil || len(final.Result.Outcomes) == 0 || len(final.Result.DataSchema) == 0 {
|
||||
t.Fatalf("%s result = %#v", test.path, final.Result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
|
||||
|
||||
命令结构:
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond|instances|share-info] 日程管理
|
||||
dws calendar attendee [list|add|delete] 参会人管理
|
||||
dws calendar room [search|add|delete|list-groups] 会议室管理
|
||||
dws calendar busy search 闲忙查询 (可查人、查会议室)
|
||||
@@ -2259,7 +2259,87 @@ func newCalendarCommand() *cobra.Command {
|
||||
eventInstancesCmd.Flags().Int("count", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("count")
|
||||
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
|
||||
eventShareInfoCmd := &cobra.Command{
|
||||
Use: "share-info",
|
||||
Aliases: []string{"share_info"},
|
||||
Short: "获取日程的分享信息",
|
||||
Long: `根据日程 ID 获取日程的分享信息,展示日程主题、组织人、地点、入会信息等,用于向他人分享日程。eventId 可通过 dws calendar event list 获取。`,
|
||||
Example: ` dws calendar event share-info --id EVENT_ID
|
||||
dws calendar event share-info --id EVENT_ID --language zh-CN
|
||||
dws calendar event share-info --id EVENT_ID --calendar-id primary`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"eventId": eventID}
|
||||
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
|
||||
toolArgs["calendarId"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "language", "lang"); v != "" {
|
||||
toolArgs["language"] = v
|
||||
}
|
||||
return callMCPTool("get_event_share_info", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(eventShareInfoCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "calendar",
|
||||
Name: "get_event_share_info",
|
||||
CanonicalPath: "calendar.get_event_share_info",
|
||||
CLIPath: "calendar event share-info",
|
||||
PrimaryCLIPath: "calendar event share-info",
|
||||
Aliases: []string{"calendar event share_info"},
|
||||
},
|
||||
Description: "获取日程的分享信息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "get_event_share_info"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取日程的分享信息(主题、组织人、地点、入会信息等)",
|
||||
UseWhen: []string{"已知 eventId,需要获取日程分享信息或入会信息用于分享给他人时"},
|
||||
AvoidWhen: []string{
|
||||
"要查看日程详情改用 dws calendar event get",
|
||||
"未知 eventId 时先 dws calendar event list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws calendar event share-info --id <EVENT_ID>",
|
||||
"dws calendar event share-info --id <EVENT_ID> --language zh-CN",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "eventId", Required: boolPtr(true)},
|
||||
{Name: "calendar-id", Property: "calendarId"},
|
||||
{Name: "language", Property: "language"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// EventShareInfo flags
|
||||
eventShareInfoCmd.Flags().String("id", "", "日程 ID (必填)")
|
||||
eventShareInfoCmd.Flags().String("event", "", "")
|
||||
_ = eventShareInfoCmd.Flags().MarkHidden("event")
|
||||
eventShareInfoCmd.Flags().String("event-id", "", "")
|
||||
_ = eventShareInfoCmd.Flags().MarkHidden("event-id")
|
||||
eventShareInfoCmd.Flags().String("eventId", "", "")
|
||||
_ = eventShareInfoCmd.Flags().MarkHidden("eventId")
|
||||
eventShareInfoCmd.Flags().String("calendar-id", "", "日历 ID (默认 primary 主日历)")
|
||||
eventShareInfoCmd.Flags().String("calendarId", "", "")
|
||||
_ = eventShareInfoCmd.Flags().MarkHidden("calendarId")
|
||||
eventShareInfoCmd.Flags().String("calendar", "", "")
|
||||
_ = eventShareInfoCmd.Flags().MarkHidden("calendar")
|
||||
eventShareInfoCmd.Flags().String("language", "", "语言代码 (可选,如 zh-CN)")
|
||||
eventShareInfoCmd.Flags().String("lang", "", "")
|
||||
_ = eventShareInfoCmd.Flags().MarkHidden("lang")
|
||||
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd, eventShareInfoCmd)
|
||||
|
||||
// participant
|
||||
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
|
||||
|
||||
+160
-67
@@ -19,15 +19,29 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"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/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// promoteLegacyChatString copies an explicitly supplied legacy flag into the
|
||||
// new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the
|
||||
// overwrite also preserves the migration rule that a legacy value wins when
|
||||
// both spellings are present.
|
||||
func promoteLegacyChatString(cmd *cobra.Command, canonical, legacy string) error {
|
||||
if !cmd.Flags().Changed(legacy) {
|
||||
return nil
|
||||
}
|
||||
value, err := cmd.Flags().GetString(legacy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.Flags().Set(canonical, value)
|
||||
}
|
||||
|
||||
func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("chat", toolName, args)
|
||||
@@ -3144,7 +3158,7 @@ func newChatCommand() *cobra.Command {
|
||||
--open-dingtalk-id 单聊接收人 openDingTalkId
|
||||
|
||||
纯文本 / Markdown 消息(默认):
|
||||
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
|
||||
无需指定 --msg-type,直接传消息内容即可。推荐使用 --content flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
|
||||
图文混排时,公网图片 URL 需要写成 Markdown 图片语法:,才会以内联图片展示。
|
||||
如果省略开头的 !,例如 [图片标题](https://example.com/image.png),将按链接/URL 展示,不会渲染为图片。
|
||||
|
||||
@@ -3154,24 +3168,24 @@ func newChatCommand() *cobra.Command {
|
||||
获取 openMessageId 和 openConversationId,无需再按消息内容反查。
|
||||
|
||||
本地图片 / 文件消息:
|
||||
统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送;
|
||||
统一使用 --msg-type file --file <本地路径>。CLI 会完成上传并按 file 消息发送;
|
||||
.png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。
|
||||
|
||||
旧版内联图片消息:
|
||||
仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。
|
||||
当前 CLI 不提供本地文件到 mediaId 的上传能力。`,
|
||||
Example: ` dws chat message send --conversation-id <openconversation_id> "hello"
|
||||
dws chat message send --user <userId> "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> "请查收"
|
||||
dws chat message send --conversation-id <openconversation_id> --title "周报提醒" "请大家本周五前提交周报"
|
||||
Example: ` dws chat message send --conversation-id <openconversation_id> --content "hello"
|
||||
dws chat message send --user <userId> --content "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --content "请查收"
|
||||
dws chat message send --conversation-id <openconversation_id> --title "周报提醒" --content "请大家本周五前提交周报"
|
||||
# 图文混排 Markdown:公网图片 URL 需要写成  才会以内联图片展示
|
||||
dws chat message send --conversation-id <openconversation_id> --text $'这是图文说明\n\n'
|
||||
dws chat message send --conversation-id <openconversation_id> --content $'这是图文说明\n\n'
|
||||
# 发送本地图片或文件(图片会作为可下载的 file 附件发送)
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type file --file-path ./screenshot.png
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type file --file-path ./report.pdf
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type file --file ./screenshot.png
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type file --file ./report.pdf
|
||||
# 发送本地音频/视频(audio/video 是 file 的语义别名)
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type audio --file-path ./recording.mp3
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type video --file-path ./demo.mp4
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type audio --file ./recording.mp3
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type video --file ./demo.mp4
|
||||
# 旧版内联图片:仅当上游已经持有有效 mediaId 时使用
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type image --media-id <mediaId>
|
||||
# 查询群 ID: dws chat search --query "群名"
|
||||
@@ -3250,7 +3264,7 @@ func newChatCommand() *cobra.Command {
|
||||
contentJSON = fmt.Sprintf(`{"mediaId":"%s"}`, mediaId)
|
||||
case "file", "audio", "video":
|
||||
serviceMsgType = "file"
|
||||
filePath, _ := cmd.Flags().GetString("file-path")
|
||||
filePath := flagOrFallback(cmd, "file-path", "file")
|
||||
dentryId, _ := cmd.Flags().GetInt64("dentry-id")
|
||||
spaceId, _ := cmd.Flags().GetInt64("space-id")
|
||||
if (dentryId == 0) != (spaceId == 0) {
|
||||
@@ -3281,7 +3295,7 @@ func newChatCommand() *cobra.Command {
|
||||
}
|
||||
contentJSON, _ = buildConversationFileContent(dentryId, spaceId, meta)
|
||||
} else if dentryId == 0 || spaceId == 0 {
|
||||
return fmt.Errorf("--file-path must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err)
|
||||
return fmt.Errorf("--file must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err)
|
||||
}
|
||||
}
|
||||
if contentJSON == "" {
|
||||
@@ -3289,7 +3303,7 @@ func newChatCommand() *cobra.Command {
|
||||
fileType, _ := cmd.Flags().GetString("file-type")
|
||||
fileSize, _ := cmd.Flags().GetInt64("file-size")
|
||||
if dentryId == 0 || spaceId == 0 || fileName == "" {
|
||||
return fmt.Errorf("readable local --file-path is required for msgType=file; legacy flags --dentry-id, --space-id, --file-name are still supported")
|
||||
return fmt.Errorf("readable local --file is required for msgType=file; legacy flags --dentry-id, --space-id, --file-name are still supported")
|
||||
}
|
||||
contentJSON = fmt.Sprintf(`{"dentryId":%d,"spaceId":%d,"fileName":"%s","fileType":"%s","filePath":"%s","fileSize":%d}`,
|
||||
dentryId, spaceId, fileName, fileType, filePath, fileSize)
|
||||
@@ -3335,7 +3349,7 @@ func newChatCommand() *cobra.Command {
|
||||
text = args[0]
|
||||
}
|
||||
if text == "" {
|
||||
return fmt.Errorf("message content required (use --text or positional arg, or --media-id for image)")
|
||||
return fmt.Errorf("message content required (use --content or positional arg, or --media-id for image)")
|
||||
}
|
||||
title, _ := cmd.Flags().GetString("title")
|
||||
if title == "" {
|
||||
@@ -3395,7 +3409,7 @@ func newChatCommand() *cobra.Command {
|
||||
AgentSummary: "以当前用户身份发送群聊或单聊消息",
|
||||
UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时;响应返回 openTaskId 后用 chat message query-send-status 确认投递并取得后续操作所需的消息 ID"},
|
||||
AvoidWhen: []string{"机器人身份或 Webhook 发送应使用对应命令"},
|
||||
Examples: []string{"dws chat message send --group <openConversationId> \"项目已更新\""},
|
||||
Examples: []string{"dws chat message send --group <openConversationId> --content \"项目已更新\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
|
||||
@@ -3413,10 +3427,11 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageSendByBotCmd := &cobra.Command{
|
||||
Use: "send-by-bot",
|
||||
Short: "机器人发送消息(--conversation-id 群聊 / --users 单聊)",
|
||||
Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。
|
||||
Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。机器人引用回复仅支持群聊 Markdown,同时传 --reply(原消息 openMessageId)和 --ref-sender(原消息发送者 openDingTalkId);回复可省略 --title,CLI 会从正文生成标题。
|
||||
|
||||
⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。`,
|
||||
Example: ` dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --title "日报" --text "## 今日完成..."
|
||||
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --reply <openMessageId> --ref-sender <openDingTalkId> --text "收到"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --msg-type image --image-url "https://example.com/image.png"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --msg-type file --file-path ./report.pdf
|
||||
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
|
||||
@@ -3433,10 +3448,17 @@ func newChatCommand() *cobra.Command {
|
||||
text := mustGetFlag(cmd, "text")
|
||||
imageURL := strings.TrimSpace(mustGetFlag(cmd, "image-url"))
|
||||
filePath := mustGetFlag(cmd, "file-path")
|
||||
referenceOpenMessageID := strings.TrimSpace(mustGetFlag(cmd, "reply"))
|
||||
refSender := strings.TrimSpace(mustGetFlag(cmd, "ref-sender"))
|
||||
replyChanged := cmd.Flags().Changed("reply")
|
||||
refSenderChanged := cmd.Flags().Changed("ref-sender")
|
||||
|
||||
if err := validateRequiredFlags(cmd, "robot-code"); err != nil {
|
||||
return err
|
||||
}
|
||||
if replyChanged != refSenderChanged || (replyChanged && (referenceOpenMessageID == "" || refSender == "")) {
|
||||
return fmt.Errorf("--reply and --ref-sender must be specified together")
|
||||
}
|
||||
if msgType == "" {
|
||||
if imageURL != "" {
|
||||
return fmt.Errorf("--msg-type image is required when using --image-url")
|
||||
@@ -3448,8 +3470,17 @@ func newChatCommand() *cobra.Command {
|
||||
}
|
||||
switch msgType {
|
||||
case "markdown":
|
||||
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
|
||||
return err
|
||||
if referenceOpenMessageID != "" {
|
||||
if err := validateRequiredFlags(cmd, "text"); err != nil {
|
||||
return err
|
||||
}
|
||||
if title == "" {
|
||||
title = sanitizeTitleFromText(text)
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "image":
|
||||
if imageURL == "" {
|
||||
@@ -3475,6 +3506,22 @@ func newChatCommand() *cobra.Command {
|
||||
return fmt.Errorf("--conversation-id or --users/--open-dingtalk-ids is required")
|
||||
}
|
||||
|
||||
if referenceOpenMessageID != "" {
|
||||
if chatID == "" {
|
||||
return fmt.Errorf("--reply and --ref-sender are only supported with --conversation-id")
|
||||
}
|
||||
if msgType != "markdown" {
|
||||
return fmt.Errorf("--reply and --ref-sender only support Markdown group messages")
|
||||
}
|
||||
if !isOpenDingTalkID(refSender) {
|
||||
resolved, err := resolveOpenDingTalkID(cmd.Context(), refSender)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refSender = resolved
|
||||
}
|
||||
}
|
||||
|
||||
userIDs := splitCommaList(usersStr)
|
||||
openDingTalkIDs := splitCommaList(openDingtalkIdsStr)
|
||||
fileURL := ""
|
||||
@@ -3540,6 +3587,10 @@ func newChatCommand() *cobra.Command {
|
||||
toolArgs["msgKey"] = "sampleMarkdownDX"
|
||||
}
|
||||
toolArgs["openConversationId"] = chatID
|
||||
if referenceOpenMessageID != "" {
|
||||
toolArgs["referenceOpenMessageId"] = referenceOpenMessageID
|
||||
toolArgs["srcMsgSendOpenDingTalkId"] = refSender
|
||||
}
|
||||
if len(atUserIds) > 0 {
|
||||
toolArgs["atUserIds"] = atUserIds
|
||||
}
|
||||
@@ -3586,17 +3637,20 @@ func newChatCommand() *cobra.Command {
|
||||
CLIPath: "chat message send-by-bot",
|
||||
PrimaryCLIPath: "chat message send-by-bot",
|
||||
},
|
||||
Description: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
|
||||
Description: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
|
||||
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时"},
|
||||
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊",
|
||||
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时", "需要机器人在群聊中引用回复一条已有消息时"},
|
||||
AvoidWhen: []string{"个人身份发送或自定义 Webhook 告警不要使用"},
|
||||
Examples: []string{"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\""},
|
||||
Examples: []string{
|
||||
"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\"",
|
||||
"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --reply <openMessageId> --ref-sender <openDingTalkId> --text \"收到\"",
|
||||
},
|
||||
},
|
||||
// Keep title/text required_when out of Schema: the runtime switch above
|
||||
// enforces Markdown inputs, while adding it breaks merge-base compatibility.
|
||||
@@ -3605,6 +3659,8 @@ func newChatCommand() *cobra.Command {
|
||||
{Name: "msg-type", RequiredWhen: "image-url or file-path is provided", Enum: []string{"markdown", "image", "file"}},
|
||||
{Name: "image-url", RequiredWhen: "msg-type is image"},
|
||||
{Name: "file-path", RequiredWhen: "msg-type is file"},
|
||||
{Name: "reply", Property: "referenceOpenMessageId", RequiredWhen: "ref-sender is provided", InterfaceType: "string"},
|
||||
{Name: "ref-sender", Property: "srcMsgSendOpenDingTalkId", RequiredWhen: "reply is provided", InterfaceType: "string"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -3674,19 +3730,25 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageSendByWebhookCmd := &cobra.Command{
|
||||
Use: "send-by-webhook",
|
||||
Short: "自定义机器人 Webhook 发送群消息",
|
||||
Long: `通过自定义机器人 Webhook 发送群消息。@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。
|
||||
Long: `通过自定义机器人 Webhook 发送群消息。@ 人时需在 --content 中包含 @userId 或 @手机号,否则 @ 不生效。
|
||||
|
||||
⚠️ 重要:该接口会真实发送消息到目标群聊,不可用于测试或试探性调用。调用前必须确认消息内容无误。`,
|
||||
Example: ` dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90%" --at-all
|
||||
dws chat message send-by-webhook --token <webhook-token> --title "test" --text "hi @118785" --at-users 118785`,
|
||||
Example: ` dws chat message send-by-webhook --token <webhook-token> --title "告警" --content "CPU 超 90%" --at-all
|
||||
dws chat message send-by-webhook --token <webhook-token> --title "test" --content "hi @118785" --at-users 118785`,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return promoteLegacyChatString(cmd, "content", "text")
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "token", "title", "text"); err != nil {
|
||||
if err := promoteLegacyChatString(cmd, "content", "text"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "token", "title", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"robotToken": mustGetFlag(cmd, "token"),
|
||||
"title": mustGetFlag(cmd, "title"),
|
||||
"text": mustGetFlag(cmd, "text"),
|
||||
"text": mustGetFlag(cmd, "content"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("at-all"); v {
|
||||
toolArgs["isAtAll"] = true
|
||||
@@ -3761,11 +3823,12 @@ func newChatCommand() *cobra.Command {
|
||||
"使用企业机器人 robot-code 发消息时用 chat message send-by-bot",
|
||||
"以个人身份发消息时用 chat message send",
|
||||
},
|
||||
Examples: []string{"dws chat message send-by-webhook --token <webhook-token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
|
||||
Examples: []string{"dws chat message send-by-webhook --token <webhook-token> --title \"告警\" --content \"CPU 超 90%\" --at-all"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "at-all", Property: "isAtAll"},
|
||||
{Name: "at-users", Property: "atUserIds"},
|
||||
{Name: "content", Property: "text"},
|
||||
{Name: "token", Property: "robotToken"},
|
||||
},
|
||||
},
|
||||
@@ -4314,7 +4377,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
Example: ` dws chat message recall --conversation-id <openConversationId> --message-id <openMessageId>
|
||||
|
||||
# 发送后撤回:send -> query-send-status -> recall
|
||||
dws chat message send --conversation-id <openConversationId> --text "待撤回的内容"
|
||||
dws chat message send --conversation-id <openConversationId> --content "待撤回的内容"
|
||||
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
|
||||
dws chat message recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -4375,7 +4438,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
Example: ` dws chat message edit --conversation-id <openConversationId> --message-id <openMessageId> --text "更新后的内容"
|
||||
|
||||
# 发送后编辑:send -> query-send-status -> edit
|
||||
dws chat message send --conversation-id <openConversationId> --text "原始内容"
|
||||
dws chat message send --conversation-id <openConversationId> --content "原始内容"
|
||||
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
|
||||
dws chat message edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId> --text "更新后的内容"
|
||||
|
||||
@@ -4711,21 +4774,19 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
chatMessageSendCmd.Flags().String("user", "", "单聊接收人 userId(单聊时与 --open-dingtalk-id 二选一)")
|
||||
chatMessageSendCmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId(单聊时与 --user 二选一)")
|
||||
chatMessageSendCmd.Flags().String("title", "", "消息标题,显示在消息列表(可选,未指定时使用消息内容)")
|
||||
// 别名注册: --text/--content/--body/--message/--markdown → 位置参数
|
||||
chatMessageSendCmd.Flags().String("text", "", "消息内容(推荐方式,也可用位置参数传递。内容含换行/特殊字符时必须使用此 flag)")
|
||||
chatMessageSendCmd.Flags().String("content", "", "--text 的别名")
|
||||
chatMessageSendCmd.Flags().String("body", "", "--text 的别名")
|
||||
chatMessageSendCmd.Flags().String("message", "", "--text 的别名")
|
||||
chatMessageSendCmd.Flags().String("markdown", "", "--text 的别名")
|
||||
// --text 不再隐藏,作为首选传参方式展示在 help 中
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("content")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("body")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("message")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("markdown")
|
||||
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
|
||||
Name: "content",
|
||||
Usage: "消息内容(推荐方式,也可用位置参数传递。内容含换行/特殊字符时必须使用此 flag)",
|
||||
Aliases: []string{"text"},
|
||||
}})
|
||||
for _, alias := range []string{"body", "message", "markdown"} {
|
||||
chatMessageSendCmd.Flags().String(alias, "", "--content 的兼容别名")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>")
|
||||
chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>")
|
||||
chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)")
|
||||
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
|
||||
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file;image 仅接受已有 mediaId)")
|
||||
chatMessageSendCmd.Flags().String("latitude", "", "位置消息纬度(msgType=location 时必填)")
|
||||
chatMessageSendCmd.Flags().String("longitude", "", "位置消息经度(msgType=location 时必填)")
|
||||
chatMessageSendCmd.Flags().String("location-name", "", "位置消息地址名称(msgType=location 时必填)")
|
||||
@@ -4735,7 +4796,11 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)")
|
||||
chatMessageSendCmd.Flags().String("file-name", "", "文件名")
|
||||
chatMessageSendCmd.Flags().String("file-type", "", "文件类型/扩展名")
|
||||
chatMessageSendCmd.Flags().String("file-path", "", "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)")
|
||||
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
|
||||
Name: "file",
|
||||
Usage: "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)",
|
||||
Aliases: []string{"file-path"},
|
||||
}})
|
||||
chatMessageSendCmd.Flags().Int64("file-size", 0, "文件大小,单位字节")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("dentry-id")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("space-id")
|
||||
@@ -4756,19 +4821,19 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
cli.AnnotateRuntimePositionals(chatMessageSendCmd, contract.RuntimeSchemaPositional{
|
||||
Name: "content",
|
||||
Type: "string",
|
||||
Description: "消息内容(也可使用 --text;富媒体消息可省略)",
|
||||
Description: "消息内容(也可使用 --content;富媒体消息可省略)",
|
||||
Required: false,
|
||||
Index: 0,
|
||||
})
|
||||
cli.AnnotateRuntimeFlagEnum(chatMessageSendCmd, "msg-type", "image", "file", "audio", "video")
|
||||
cli.AnnotateRuntimeFlagFormat(chatMessageSendCmd, "file-path", "file-path")
|
||||
cli.AnnotateRuntimeFlagFormat(chatMessageSendCmd, "file", "file-path")
|
||||
|
||||
chatMessageSendByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)")
|
||||
_ = chatMessageSendByBotCmd.MarkFlagRequired("robot-code")
|
||||
chatMessageSendByBotCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时必填)")
|
||||
chatMessageSendByBotCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔,最多20个(单聊时必填)")
|
||||
chatMessageSendByBotCmd.Flags().String("msg-type", "", "消息类型: markdown/image/file(省略时为 markdown;图片使用 image --image-url;本地文件使用 file --file-path)")
|
||||
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送 Markdown 时必填)")
|
||||
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送普通 Markdown 时必填;引用回复省略时从正文生成)")
|
||||
chatMessageSendByBotCmd.Flags().String("text", "", "Markdown 消息内容(发送 Markdown 时必填;稳定换行用空行,转义形式写 \\n\\n,不要只写 \\n)")
|
||||
chatMessageSendByBotCmd.Flags().String("image-url", "", "公网图片 URL(msgType=image 时必填)")
|
||||
chatMessageSendByBotCmd.Flags().String("file-path", "", "本地文件路径(msgType=file 时直接上传并按 file 消息发送)")
|
||||
@@ -4776,9 +4841,13 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
chatMessageSendByBotCmd.Flags().String("open-dingtalk-ids", "", "用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)")
|
||||
chatMessageSendByBotCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)")
|
||||
chatMessageSendByBotCmd.Flags().Bool("at-all", false, "@所有人(可选),服务端接收字符串 true/false")
|
||||
chatMessageSendByBotCmd.Flags().String("reply", "", "被引用消息的 openMessageId(仅群聊 Markdown;必须与 --ref-sender 同时使用)")
|
||||
chatMessageSendByBotCmd.Flags().String("ref-sender", "", "被引用消息发送者的 openDingTalkId(仅群聊 Markdown;必须与 --reply 同时使用)")
|
||||
chatMessageSendByBotCmd.MarkFlagsRequiredTogether("reply", "ref-sender")
|
||||
cli.AnnotateRuntimeConstraints(chatMessageSendByBotCmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"group", "users"}},
|
||||
RequireOneOf: [][]string{{"group", "users"}},
|
||||
RequireTogether: [][]string{{"reply", "ref-sender"}},
|
||||
})
|
||||
cli.AnnotateRuntimeFlagFormat(chatMessageSendByBotCmd, "file-path", "file-path")
|
||||
|
||||
@@ -4792,8 +4861,12 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("token")
|
||||
chatMessageSendByWebhookCmd.Flags().String("title", "", "消息标题 (必填)")
|
||||
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("title")
|
||||
chatMessageSendByWebhookCmd.Flags().String("text", "", "消息内容 (必填)")
|
||||
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("text")
|
||||
corecmd.RegisterFlags(chatMessageSendByWebhookCmd, []corecmd.FlagSpec{{
|
||||
Name: "content",
|
||||
Usage: "消息内容 (必填)",
|
||||
Aliases: []string{"text"},
|
||||
}})
|
||||
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("content")
|
||||
chatMessageSendByWebhookCmd.Flags().Bool("at-all", false, "@ 所有人")
|
||||
chatMessageSendByWebhookCmd.Flags().String("at-mobiles", "", "@ 指定手机号,逗号分隔")
|
||||
chatMessageSendByWebhookCmd.Flags().String("at-users", "", "@ 指定用户,逗号分隔")
|
||||
@@ -5003,7 +5076,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
Use: "conversation-info",
|
||||
Short: "获取会话基础信息",
|
||||
Long: `获取指定会话的基础信息。
|
||||
发送本地文件消息请优先使用 dws chat message send --msg-type file --file-path <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`,
|
||||
发送本地文件消息请优先使用 dws chat message send --msg-type file --file <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`,
|
||||
Example: ` dws chat conversation-info --conversation-id <openConversationId> --format json
|
||||
dws chat conversation-info --user <userId> --format json
|
||||
dws chat conversation-info --open-dingtalk-id <openDingTalkId> --format json`,
|
||||
@@ -5118,11 +5191,11 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
Hidden: true,
|
||||
Long: `chat file upload 已下线,不再调用 chat/upload_conversation_file_by_url。
|
||||
|
||||
发送本地文件消息请改用 chat message send --msg-type file --file-path;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`,
|
||||
Example: ` dws chat message send --conversation-id <openConversationId> --msg-type file --file-path ./report.pdf --format json
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf --format json`,
|
||||
发送本地文件消息请改用 chat message send --msg-type file --file;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`,
|
||||
Example: ` dws chat message send --conversation-id <openConversationId> --msg-type file --file ./report.pdf --format json
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf --format json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file-path <本地路径>")
|
||||
return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file <本地路径>")
|
||||
},
|
||||
}
|
||||
chatFileUploadCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时使用)")
|
||||
@@ -6888,10 +6961,22 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
- 群聊:dws chat search --query "群名"
|
||||
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
|
||||
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
|
||||
Example: ` dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content "收到,马上处理"
|
||||
dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return promoteLegacyChatString(cmd, "content", "text")
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
|
||||
if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := promoteLegacyChatString(cmd, "content", "text"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "group", "ref-msg-id", "ref-sender", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
refSender := mustGetFlag(cmd, "ref-sender")
|
||||
@@ -6908,7 +6993,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
clawType = edition.ClawType()
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "conversation-id"),
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"msgType": "reply",
|
||||
"clawType": clawType,
|
||||
}
|
||||
@@ -6916,7 +7001,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
|
||||
replyText := applyCurrentUserGroupMentions(
|
||||
toolArgs,
|
||||
mustGetFlag(cmd, "text"),
|
||||
mustGetFlag(cmd, "content"),
|
||||
atOpenIDs,
|
||||
atAll,
|
||||
)
|
||||
@@ -6958,24 +7043,32 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
AgentSummary: "引用指定消息发送个人回复",
|
||||
UseWhen: []string{"用户要针对某条已有消息进行引用回复时"},
|
||||
AvoidWhen: []string{"无需引用上下文的普通消息使用 chat message send"},
|
||||
Examples: []string{"dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text \"收到\""},
|
||||
Examples: []string{"dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content \"收到\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
|
||||
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
|
||||
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
|
||||
{Name: "conversation-id", Property: "openConversationId"},
|
||||
{Name: "group", Property: "openConversationId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
chatMessageReplyCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("conversation-id")
|
||||
corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{
|
||||
Name: "group",
|
||||
Usage: "会话 openConversationId (必填,支持单聊/群聊)",
|
||||
Aliases: []string{"conversation-id"},
|
||||
}})
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("group")
|
||||
chatMessageReplyCmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("ref-msg-id")
|
||||
chatMessageReplyCmd.Flags().String("ref-sender", "", "被引用消息的发送者 openDingTalkId (必填)")
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("ref-sender")
|
||||
chatMessageReplyCmd.Flags().String("text", "", "回复内容 (必填)")
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("text")
|
||||
corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{
|
||||
Name: "content",
|
||||
Usage: "回复内容 (必填)",
|
||||
Aliases: []string{"text"},
|
||||
}})
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("content")
|
||||
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
|
||||
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
|
||||
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
|
||||
|
||||
@@ -446,6 +446,100 @@ func TestCrossPlatformCoverageChatBotRichMediaCoverage(t *testing.T) {
|
||||
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleMarkdownDX" {
|
||||
t.Fatalf("direct markdown call = %s %#v", caller.tool, caller.args)
|
||||
}
|
||||
if _, exists := caller.args["referenceOpenMessageId"]; exists {
|
||||
t.Fatalf("ordinary direct message unexpectedly contains reply fields: %#v", caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group Markdown reference reply", func(t *testing.T) {
|
||||
const senderOpenDingTalkID = "DAAAAAAAAAAAiE"
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
|
||||
"--reply", "message-id", "--ref-sender", senderOpenDingTalkID,
|
||||
"--text", "received",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "send_robot_group_message" ||
|
||||
caller.args["referenceOpenMessageId"] != "message-id" ||
|
||||
caller.args["srcMsgSendOpenDingTalkId"] != senderOpenDingTalkID ||
|
||||
caller.args["title"] != "received" {
|
||||
t.Fatalf("group reference reply call = %s %#v", caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group reference reply resolves sender user ID", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":[{"userId":"sender-user","openDingTalkId":"D-sender"}]}`},
|
||||
{text: `{}`},
|
||||
}}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
|
||||
"--reply", "message-id", "--ref-sender", "sender-user",
|
||||
"--title", "reply", "--text", "received",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "send_robot_group_message" || caller.args["srcMsgSendOpenDingTalkId"] != "D-sender" {
|
||||
t.Fatalf("resolved group reference reply = %s %#v", caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct RunE enforces paired reply flags", func(t *testing.T) {
|
||||
err := runChatCoverageDirect(t, []string{"message", "send-by-bot"}, map[string]string{
|
||||
"robot-code": "robot",
|
||||
"conversation-id": "group",
|
||||
"reply": "message-id",
|
||||
"title": "reply",
|
||||
"text": "received",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "must be specified together") {
|
||||
t.Fatalf("direct RunE paired reply flags error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "reply requires sender", args: []string{"--reply", "message-id"}, want: "ref-sender"},
|
||||
{name: "sender requires reply", args: []string{"--ref-sender", "D-sender"}, want: "reply"},
|
||||
{name: "paired reply flags reject empty values", args: []string{"--reply=", "--ref-sender="}, want: "specified together"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
|
||||
"--title", "reply", "--text", "received",
|
||||
}
|
||||
err := runChatCoverageCommand(t, &scriptedToolCaller{}, append(args, tc.args...)...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("paired reply flags error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("reference reply is group only", func(t *testing.T) {
|
||||
err := runChatCoverageCommand(t, &scriptedToolCaller{},
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
|
||||
"--reply", "message-id", "--ref-sender", "D-sender",
|
||||
"--title", "reply", "--text", "received",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
|
||||
t.Fatalf("direct reference reply error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reference reply rejects rich media", func(t *testing.T) {
|
||||
err := runChatCoverageCommand(t, &scriptedToolCaller{},
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
|
||||
"--reply", "message-id", "--ref-sender", "D-sender",
|
||||
"--msg-type", "image", "--image-url", "https://example.test/image.png",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support Markdown") {
|
||||
t.Fatalf("rich-media reference reply error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct image", func(t *testing.T) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const chatMediaUploadReplacement = "dws chat message send --msg-type file --file-path <本地路径>"
|
||||
const chatMediaUploadReplacement = "dws chat message send --msg-type file --file <本地路径>"
|
||||
|
||||
func newChatMediaGroup() *cobra.Command {
|
||||
media := &cobra.Command{
|
||||
@@ -43,11 +43,11 @@ func newChatMediaUploadCommand() *cobra.Command {
|
||||
Deprecated: "请改用 " + chatMediaUploadReplacement,
|
||||
Long: `此命令仅为 1.x 命令行兼容保留,不再读取应用凭证或调用旧版媒体上传接口。
|
||||
|
||||
发送本地图片或文件时,请使用 chat message send --msg-type file --file-path。
|
||||
发送本地图片或文件时,请使用 chat message send --msg-type file --file。
|
||||
该路径会把图片作为可下载的 file 消息发送,不会生成 mediaId,也不会渲染成内联 image 消息。
|
||||
如果上游已经提供 mediaId,仍可使用 chat message send --msg-type image --media-id。`,
|
||||
Example: " dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png\n" +
|
||||
" dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf",
|
||||
Example: " dws chat message send --conversation-id <openConversationId> --msg-type file --file ./screenshot.png\n" +
|
||||
" dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
@@ -56,7 +56,7 @@ func newChatMediaUploadCommand() *cobra.Command {
|
||||
}
|
||||
// Keep the historical flags so existing argv remains parseable while the
|
||||
// 1.x compatibility command returns an actionable migration error.
|
||||
cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file-path")
|
||||
cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file")
|
||||
cmd.Flags().String("type", "image", "旧版兼容参数;不再执行媒体上传")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestCrossPlatformCoverageChatMediaUploadIsDeprecatedCompatibilityStub(t *te
|
||||
if err == nil {
|
||||
t.Fatal("deprecated media upload returned nil error")
|
||||
}
|
||||
for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file-path", "--media-id"} {
|
||||
for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file", "--media-id"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("media upload migration error missing %q: %v", want, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const commentServer = "doc-comment"
|
||||
|
||||
var commentBatchResultSchema = json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"按请求顺序返回的评论详情",
|
||||
"properties":{
|
||||
"commentList":{
|
||||
"type":"array",
|
||||
"description":"与请求 comments 顺序一致的评论详情列表",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"description":"单条评论查询结果;不存在时 found=false",
|
||||
"properties":{
|
||||
"topicId":{"type":"string","description":"评论主题 ID"},
|
||||
"commentKey":{"type":"string","description":"评论唯一标识"},
|
||||
"found":{"type":"boolean","description":"该 topicId/commentKey 是否存在"},
|
||||
"content":{"type":["string","null"],"description":"纯文本评论内容"},
|
||||
"quote":{"type":["string","null"],"description":"划词评论引用内容"},
|
||||
"creatorId":{"type":["string","null"],"description":"创建者用户 ID"},
|
||||
"createTime":{"type":["integer","null"],"description":"创建时间,毫秒时间戳"},
|
||||
"updateTime":{"type":["integer","null"],"description":"更新时间,毫秒时间戳"},
|
||||
"isSolved":{"type":["boolean","null"],"description":"是否已解决"},
|
||||
"isEmoji":{"type":["boolean","null"],"description":"是否为表情回复"},
|
||||
"replyCommentKey":{"type":["string","null"],"description":"被回复评论的标识"}
|
||||
},
|
||||
"required":["topicId","commentKey","found"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["commentList"],
|
||||
"additionalProperties":true
|
||||
}`)
|
||||
|
||||
var commentStatusResultSchema = json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"评论解决状态更新结果",
|
||||
"properties":{
|
||||
"commentKey":{"type":"string","description":"评论唯一标识"},
|
||||
"resolved":{"type":"boolean","description":"更新后的解决状态"},
|
||||
"message":{"type":"string","description":"操作结果消息"}
|
||||
},
|
||||
"required":["commentKey","resolved"],
|
||||
"additionalProperties":true
|
||||
}`)
|
||||
|
||||
var commentReactionResultSchema = json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"表情回复创建结果",
|
||||
"properties":{
|
||||
"commentKey":{"type":"string","description":"新建表情回复的评论标识"},
|
||||
"message":{"type":"string","description":"操作结果消息"}
|
||||
},
|
||||
"required":["commentKey"],
|
||||
"additionalProperties":true
|
||||
}`)
|
||||
|
||||
// newCommentBaseCommands returns the base comment commands shared by Doc and Sheet.
|
||||
// Both resource domains call the same comment MCP tools; only CLI identity and
|
||||
// guidance differ.
|
||||
func newCommentBaseCommands(surface string) []*cobra.Command {
|
||||
batchCmd := newCommentBatchQueryCommand(surface)
|
||||
resolveCmd := newCommentStatusCommand(surface, true)
|
||||
restoreCmd := newCommentStatusCommand(surface, false)
|
||||
reactCmd := newCommentReactionCommand(surface)
|
||||
commands := []*cobra.Command{batchCmd, resolveCmd, restoreCmd, reactCmd}
|
||||
for _, cmd := range commands {
|
||||
addCommentNodeAliases(cmd)
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
func newCommentBatchQueryCommand(surface string) *cobra.Command {
|
||||
resourceName := commentResourceName(surface)
|
||||
cmd := &cobra.Command{
|
||||
Use: "batch-query",
|
||||
Aliases: []string{"batch-query-comments", "batch"},
|
||||
Short: "按 topicId + commentKey 批量查询评论详情",
|
||||
Long: `批量查询同一文档或表格中的评论详情,单次最多 100 条。
|
||||
|
||||
--comment-ref 可重复传入,格式为 topicId:commentKey。topicId 和 commentKey
|
||||
均可从 comment list 的返回结果中获得。结果严格保持输入顺序;不存在的评论
|
||||
会返回 found=false 的占位项。`,
|
||||
Example: fmt.Sprintf(" dws %s comment batch-query --node <NODE_ID> --comment-ref global:<COMMENT_KEY> --comment-ref <TOPIC_ID>:<COMMENT_KEY> --format json", surface),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawRefs, _ := cmd.Flags().GetStringSlice("comment-ref")
|
||||
refs, err := parseCommentRefs(rawRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer(commentServer, "batch_query_comments", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"comments": refs,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: commentIdentity(surface, "batch_query_comments", "batch-query"),
|
||||
Description: "按 topicId + commentKey 批量查询评论详情,保持输入顺序并显式标记缺失项",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: commentBatchResultSchema,
|
||||
},
|
||||
Interface: commentInterface("batch_query_comments"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "已知多组 topicId/commentKey 时,一次获取完整评论详情",
|
||||
UseWhen: []string{fmt.Sprintf("需要回查%s comment list 结果中的多条评论详情,或批量核对评论是否仍存在时", resourceName)},
|
||||
AvoidWhen: []string{fmt.Sprintf("尚未取得%s评论的 topicId/commentKey 时先使用 comment list", resourceName)},
|
||||
Examples: []string{fmt.Sprintf("dws %s comment batch-query --node <NODE_ID> --comment-ref <TOPIC_ID>:<COMMENT_KEY> --format json", surface)},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "comment-ref", Property: "comments", InterfaceType: "array"},
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
|
||||
cmd.Flags().StringSlice("comment-ref", nil, "评论引用 topicId:commentKey,可重复;最多 100 条 (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCommentStatusCommand(surface string, resolve bool) *cobra.Command {
|
||||
resourceName := commentResourceName(surface)
|
||||
use := "restore"
|
||||
rpc := "restore_comment"
|
||||
short := "将已解决评论恢复为未解决"
|
||||
resolved := false
|
||||
if resolve {
|
||||
use = "resolve"
|
||||
rpc = "resolve_comment"
|
||||
short = "将评论标记为已解决"
|
||||
resolved = true
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Aliases: []string{use + "-comment"},
|
||||
Short: short,
|
||||
Example: fmt.Sprintf(" dws %s comment %s --node <NODE_ID> --comment-key <COMMENT_KEY> --format json", surface, use),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer(commentServer, rpc, map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"commentKey": mustGetFlag(cmd, "comment-key"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: commentIdentity(surface, rpc, use),
|
||||
Description: short,
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: commentStatusResultSchema,
|
||||
},
|
||||
Interface: commentInterface(rpc),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: short,
|
||||
UseWhen: []string{fmt.Sprintf("用户明确要求把%s中的某条评论标记为%s时", resourceName, map[bool]string{true: "已解决", false: "未解决"}[resolved])},
|
||||
AvoidWhen: []string{fmt.Sprintf("永久删除%s评论使用 comment delete;修改文字使用 comment update", resourceName)},
|
||||
Examples: []string{fmt.Sprintf("dws %s comment %s --node <NODE_ID> --comment-key <COMMENT_KEY> --format json", surface, use)},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "comment-key", Property: "commentKey"},
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
|
||||
cmd.Flags().String("comment-key", "", "目标评论的 commentKey (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCommentReactionCommand(surface string) *cobra.Command {
|
||||
resourceName := commentResourceName(surface)
|
||||
cmd := &cobra.Command{
|
||||
Use: "react-reply",
|
||||
Short: "创建一条表情回复",
|
||||
Long: `创建表情回复的便捷命令。底层复用 reply_comment,并固定传入 emoji=true。
|
||||
|
||||
与 comment reply --emoji 一致,--reaction 必须填写钉钉表情名称,不要直接传
|
||||
Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传“鼓掌”。
|
||||
|
||||
当前轻量版仅支持创建,不包含删除或聚合能力。`,
|
||||
Example: fmt.Sprintf(" dws %s comment react-reply --node <NODE_ID> --comment-key <COMMENT_KEY> --reaction \"憨笑\" --format json", surface),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
"content": mustGetFlag(cmd, "reaction"),
|
||||
"emoji": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: commentIdentity(surface, "react_reply", "react-reply"),
|
||||
Description: "为指定评论创建表情回复(轻量版)",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: commentReactionResultSchema,
|
||||
},
|
||||
Interface: commentInterface("reply_comment"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "使用钉钉表情名称为指定评论创建表情回复",
|
||||
UseWhen: []string{fmt.Sprintf("用户要用表情回应%s中的某条评论时;先将 Unicode Emoji 转为钉钉表情名称,例如 😄 转为憨笑、👏 转为鼓掌", resourceName)},
|
||||
AvoidWhen: []string{fmt.Sprintf("%s评论的普通文字回复使用 comment reply;当前轻量版不支持删除 reaction", resourceName)},
|
||||
Examples: []string{fmt.Sprintf("dws %s comment react-reply --node <NODE_ID> --comment-key <COMMENT_KEY> --reaction \"憨笑\" --format json", surface)},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "comment-key", Property: "replyCommentKey"},
|
||||
{Name: "reaction", Property: "content"},
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
|
||||
cmd.Flags().String("comment-key", "", "被回应评论的 commentKey (必填)")
|
||||
cmd.Flags().String("reaction", "", "钉钉表情名称,不是 Unicode Emoji;例如 😄=憨笑、👏=鼓掌 (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseCommentRefs(rawRefs []string) ([]map[string]any, error) {
|
||||
if len(rawRefs) == 0 {
|
||||
return nil, fmt.Errorf("missing required flag(s): --comment-ref")
|
||||
}
|
||||
if len(rawRefs) > 100 {
|
||||
return nil, fmt.Errorf("--comment-ref 最多可传 100 条,当前为 %d 条", len(rawRefs))
|
||||
}
|
||||
refs := make([]map[string]any, 0, len(rawRefs))
|
||||
for index, raw := range rawRefs {
|
||||
parts := strings.SplitN(strings.TrimSpace(raw), ":", 2)
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
||||
return nil, fmt.Errorf("invalid --comment-ref[%d] %q: expected topicId:commentKey", index, raw)
|
||||
}
|
||||
refs = append(refs, map[string]any{
|
||||
"topicId": strings.TrimSpace(parts[0]),
|
||||
"commentKey": strings.TrimSpace(parts[1]),
|
||||
})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func addCommentNodeAliases(cmd *cobra.Command) {
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("id", "", "")
|
||||
cmd.Flags().String("node-id", "", "")
|
||||
cmd.Flags().String("doc-id", "", "")
|
||||
cmd.Flags().String("file-id", "", "")
|
||||
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
|
||||
func commentIdentity(surface, name, cliLeaf string) contract.ToolIdentitySpec {
|
||||
return contract.ToolIdentitySpec{
|
||||
ProductID: surface,
|
||||
Name: name,
|
||||
CanonicalPath: surface + "." + name,
|
||||
CLIPath: surface + " comment " + cliLeaf,
|
||||
PrimaryCLIPath: surface + " comment " + cliLeaf,
|
||||
}
|
||||
}
|
||||
|
||||
func commentResourceName(surface string) string {
|
||||
if surface == "sheet" {
|
||||
return "表格"
|
||||
}
|
||||
return "文档"
|
||||
}
|
||||
|
||||
func commentInterface(rpc string) *contract.InterfaceSpec {
|
||||
return &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: commentServer, RPCName: rpc},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeCommentBaseCommand(t *testing.T, caller *docCommentMutationCaller, surface string, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = []string{"dws", surface}
|
||||
var cmd *cobra.Command
|
||||
if surface == "sheet" {
|
||||
cmd = newSheetCommand()
|
||||
} else {
|
||||
cmd = newDocCommand()
|
||||
}
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentBaseCommandsRegisteredForDocAndSheet(t *testing.T) {
|
||||
for _, surface := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
}{
|
||||
{name: "doc", cmd: newDocCommand()},
|
||||
{name: "sheet", cmd: newSheetCommand()},
|
||||
} {
|
||||
for _, leaf := range []string{"batch-query", "resolve", "restore", "react-reply"} {
|
||||
cmd, remaining, err := surface.cmd.Find([]string{"comment", leaf})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws %s comment %s not registered: cmd=%v remaining=%v err=%v",
|
||||
surface.name, leaf, cmd, remaining, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCommentBatchQueryMapsStructuredRefs(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeCommentBaseCommand(t, caller, "doc",
|
||||
"comment", "batch-query", "--node", "doc-1",
|
||||
"--comment-ref", "global:comment-1", "--comment-ref", "topic-2:comment-2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: commentServer,
|
||||
toolName: "batch_query_comments",
|
||||
args: map[string]any{
|
||||
"nodeId": "doc-1",
|
||||
"comments": []map[string]any{
|
||||
{"topicId": "global", "commentKey": "comment-1"},
|
||||
{"topicId": "topic-2", "commentKey": "comment-2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetCommentResolveAndRestoreUseSharedRPCs(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
if err := executeCommentBaseCommand(t, caller, "sheet",
|
||||
"comment", "resolve", "--node", "sheet-1", "--comment-key", "comment-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := executeCommentBaseCommand(t, caller, "sheet",
|
||||
"comment", "restore", "--node", "sheet-1", "--comment-key", "comment-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].toolName != "resolve_comment" || caller.calls[1].toolName != "restore_comment" {
|
||||
t.Fatalf("tools = %q, %q", caller.calls[0].toolName, caller.calls[1].toolName)
|
||||
}
|
||||
wantArgs := map[string]any{"nodeId": "sheet-1", "commentKey": "comment-1"}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, wantArgs) || !reflect.DeepEqual(caller.calls[1].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeCommentBaseCommand(t, caller, "doc",
|
||||
"comment", "react-reply", "--file-id", "doc-1",
|
||||
"--comment-key", "comment-1", "--reaction", "鼓掌")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: commentServer,
|
||||
toolName: "reply_comment",
|
||||
args: map[string]any{
|
||||
"nodeId": "doc-1",
|
||||
"replyCommentKey": "comment-1",
|
||||
"content": "鼓掌",
|
||||
"emoji": true,
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
|
||||
for _, surface := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
}{
|
||||
{name: "doc", cmd: newDocCommand()},
|
||||
{name: "sheet", cmd: newSheetCommand()},
|
||||
} {
|
||||
cmd, remaining, err := surface.cmd.Find([]string{"comment", "react-reply"})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws %s comment react-reply lookup: remaining=%v err=%v", surface.name, remaining, err)
|
||||
}
|
||||
reaction := cmd.Flags().Lookup("reaction")
|
||||
if reaction == nil || !strings.Contains(reaction.Usage, "不是 Unicode Emoji") || !strings.Contains(reaction.Usage, "😄=憨笑") {
|
||||
t.Fatalf("dws %s reaction guidance = %#v", surface.name, reaction)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "不要直接传") || !strings.Contains(cmd.Example, `--reaction "憨笑"`) {
|
||||
t.Fatalf("dws %s react-reply help missing DingTalk emoji-name guidance", surface.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseCommentRefsRejectsInvalidShapeAndLimit(t *testing.T) {
|
||||
if _, err := parseCommentRefs(nil); err == nil {
|
||||
t.Fatal("missing refs returned nil error")
|
||||
}
|
||||
if _, err := parseCommentRefs([]string{"missing-separator"}); err == nil {
|
||||
t.Fatal("invalid ref returned nil error")
|
||||
}
|
||||
tooMany := make([]string, 101)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = "global:comment"
|
||||
}
|
||||
if _, err := parseCommentRefs(tooMany); err == nil {
|
||||
t.Fatal("101 refs returned nil error")
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestCallbackInboundMediaPreservesFutureRichTextAttachment(t *testing.T) {
|
||||
|
||||
// TestExtractCallbackText covers the markdown / richText fallback path used
|
||||
// when SDK data.Text.Content is empty. This is the recovery path for
|
||||
// `dws chat message send --group ... --text ...` (defaults to msgType=markdown)
|
||||
// `dws chat message send --conversation-id ... --content ...` (defaults to msgType=markdown)
|
||||
// which otherwise gets silently dropped by the connector.
|
||||
func TestExtractCallbackText(t *testing.T) {
|
||||
cases := []struct {
|
||||
|
||||
@@ -1259,7 +1259,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
}
|
||||
// Structured-text fallback: DingTalk leaves data.Text.Content blank on
|
||||
// markdown / richText callbacks (the body ships in data.Content). Without
|
||||
// this, `dws chat message send --group ... --text ...` — which defaults
|
||||
// this, `dws chat message send --conversation-id ... --content ...` — which defaults
|
||||
// to msgType=markdown — hits the drop branch below and the bot looks
|
||||
// dead to the sender.
|
||||
if text == "" {
|
||||
|
||||
+24
-15
@@ -15,10 +15,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"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/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -1018,7 +1018,7 @@ func inferMimeType(fileName string) string {
|
||||
}
|
||||
|
||||
// buildBlockElement 从 flags 构建块元素 JSON 对象。
|
||||
// 优先级: --element (原始 JSON) > --heading > --text
|
||||
// 优先级: --element (原始 JSON) > --heading > --text (旧名) > --content
|
||||
func buildBlockElement(cmd *cobra.Command) (any, error) {
|
||||
if raw, _ := cmd.Flags().GetString("element"); raw != "" {
|
||||
var obj any
|
||||
@@ -1037,13 +1037,13 @@ func buildBlockElement(cmd *cobra.Command) (any, error) {
|
||||
"heading": map[string]any{"text": h, "level": level},
|
||||
}, nil
|
||||
}
|
||||
if t, _ := cmd.Flags().GetString("text"); t != "" {
|
||||
if t := flagOrFallback(cmd, "text", "content"); t != "" {
|
||||
return map[string]any{
|
||||
"blockType": "paragraph",
|
||||
"paragraph": map[string]any{"text": t},
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("block content required: --text / --heading / --element")
|
||||
return nil, fmt.Errorf("block content required: --content / --heading / --element")
|
||||
}
|
||||
|
||||
// buildNodeTransferRunE creates a RunE handler for copy/move commands.
|
||||
@@ -2127,12 +2127,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
Use: "insert",
|
||||
Short: "插入块元素",
|
||||
Long: `向文档插入块元素。通过 --element 传入 JSON 格式的块元素。
|
||||
可用 --text 快速插入段落,--heading + --level 快速插入标题。
|
||||
可用 --content 快速插入段落,--heading + --level 快速插入标题。
|
||||
|
||||
块类型: paragraph, heading, blockquote, callout, columns,
|
||||
orderedList, unorderedList, table, sheet, attachment, slot`,
|
||||
Example: ` # 快捷插入段落
|
||||
dws doc block insert --node DOC_ID --text "这是一段文字"
|
||||
dws doc block insert --node DOC_ID --content "这是一段文字"
|
||||
|
||||
# 快捷插入标题
|
||||
dws doc block insert --node DOC_ID --heading "二级标题" --level 2
|
||||
@@ -2155,7 +2155,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
dws doc block insert --node DOC_ID --element '{"blockType":"unorderedList","unorderedList":{"list":{"listId":"list-2"}},"children":[{"text":"第二项"}]}'
|
||||
|
||||
# 在指定位置之前插入
|
||||
dws doc block insert --node DOC_ID --text "插入内容" --ref-block BLOCK_ID --where before`,
|
||||
dws doc block insert --node DOC_ID --content "插入内容" --ref-block BLOCK_ID --where before`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -2241,14 +2241,14 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "向文档插入块元素",
|
||||
UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --text/--heading,复杂块用 --element JSON"},
|
||||
UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --content/--heading,复杂块用 --element JSON"},
|
||||
AvoidWhen: []string{
|
||||
"整篇追加 Markdown 优先 doc update --mode append",
|
||||
"插入本地文件附件优先 doc media insert",
|
||||
"删块用 block delete;改已有块用 block update",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws doc block insert --node <DOC_ID> --text \"这是一段文字\" --format json",
|
||||
"dws doc block insert --node <DOC_ID> --content \"这是一段文字\" --format json",
|
||||
"dws doc block insert --node <DOC_ID> --heading \"二级标题\" --level 2 --format json",
|
||||
},
|
||||
},
|
||||
@@ -2265,8 +2265,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
Use: "update",
|
||||
Short: "更新块元素",
|
||||
Long: `更新文档中指定块的内容或样式。需提供 --block-id 和块元素内容。
|
||||
可用 --text 快速更新为段落,--heading + --level 快速更新为标题。`,
|
||||
Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --text "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node <nodeId>
|
||||
可用 --content 快速更新为段落,--heading + --level 快速更新为标题。`,
|
||||
Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --content "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node <nodeId>
|
||||
dws doc block update --node DOC_ID --block-id BLOCK_ID --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "block-id"); err != nil {
|
||||
@@ -2338,7 +2338,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
"插入新块用 block insert;删除用 block delete",
|
||||
"改文档显示名用 rename;整篇覆盖用 update overwrite",
|
||||
},
|
||||
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text \"新内容\" --format json"},
|
||||
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content \"新内容\" --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "content-format", Property: "format"},
|
||||
@@ -2748,7 +2748,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
|
||||
// block insert
|
||||
blockInsertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||||
blockInsertCmd.Flags().String("text", "", "快捷: 段落文本内容")
|
||||
blockInsertCmd.Flags().String("heading", "", "快捷: 标题文本")
|
||||
blockInsertCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
|
||||
blockInsertCmd.Flags().String("element", "", "块元素 JSON (高级)")
|
||||
@@ -2762,7 +2761,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
// block update
|
||||
blockUpdateCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||||
blockUpdateCmd.Flags().String("block-id", "", "目标块 ID (必填)")
|
||||
blockUpdateCmd.Flags().String("text", "", "快捷: 段落文本内容")
|
||||
blockUpdateCmd.Flags().String("heading", "", "快捷: 标题文本")
|
||||
blockUpdateCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
|
||||
blockUpdateCmd.Flags().String("element", "", "块元素 JSON (高级)")
|
||||
@@ -3517,6 +3515,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
}
|
||||
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd)
|
||||
commentCmd.AddCommand(newCommentBaseCommands("doc")...)
|
||||
|
||||
// ── permission (文档协作权限) ────────────────────────────
|
||||
permissionCmd := &cobra.Command{
|
||||
@@ -4643,6 +4642,16 @@ CLI 内部自动完成全部流程:
|
||||
RegisterCrossProductAliases(child)
|
||||
}
|
||||
}
|
||||
// Register the block content Primary after the cross-product pass. Registering
|
||||
// --content earlier would make the global content/markdown group expand a new
|
||||
// --markdown alias onto these commands, which is outside this migration.
|
||||
for _, cmd := range []*cobra.Command{blockInsertCmd, blockUpdateCmd} {
|
||||
corecmd.RegisterFlags(cmd, []corecmd.FlagSpec{{
|
||||
Name: "content",
|
||||
Usage: "快捷: 段落文本内容",
|
||||
Aliases: []string{"text"},
|
||||
}})
|
||||
}
|
||||
|
||||
// ── deprecated 标记:文件管理命令已迁移到 drive ──
|
||||
deprecatedDocToDrive := map[*cobra.Command]string{
|
||||
|
||||
+286
-3
@@ -10,7 +10,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -42,6 +45,283 @@ func oaFormValues(raw string) ([]map[string]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var items []map[string]any
|
||||
if err := decoder.Decode(&items); err != nil {
|
||||
return nil, fmt.Errorf("--file-infos JSON 解析失败: %w", err)
|
||||
}
|
||||
if err := rejectTrailingOAAttachmentJSON(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) < 1 || len(items) > 10 {
|
||||
return nil, fmt.Errorf("--file-infos 必须包含 1 至 10 个文件")
|
||||
}
|
||||
|
||||
infos := make([]map[string]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
for name := range item {
|
||||
if name != "spaceId" && name != "fileId" {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项包含未知字段 %q", index+1, name)
|
||||
}
|
||||
}
|
||||
|
||||
spaceValue, ok := item["spaceId"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1)
|
||||
}
|
||||
spaceID, ok := spaceValue.(json.Number)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1)
|
||||
}
|
||||
|
||||
fileValue, ok := item["fileId"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1)
|
||||
}
|
||||
fileID, ok := fileValue.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串", index+1)
|
||||
}
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
if fileID == "" {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 不能为空", index+1)
|
||||
}
|
||||
|
||||
infos = append(infos, map[string]any{"spaceId": spaceID, "fileId": fileID})
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
func rejectTrailingOAAttachmentJSON(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("--file-infos JSON 包含多余内容")
|
||||
}
|
||||
return fmt.Errorf("--file-infos JSON 解析失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOAAttachmentFileInfos(cmd *cobra.Command, _ []string) error {
|
||||
raw, _ := cmd.Flags().GetString("file-infos")
|
||||
_, err := parseOAAttachmentFileInfos(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
|
||||
fileIDs, _ := cmd.Flags().GetStringSlice("file-ids")
|
||||
if len(fileIDs) > 20 {
|
||||
return fmt.Errorf("--file-ids 最多包含 20 个附件 ID")
|
||||
}
|
||||
for index, fileID := range fileIDs {
|
||||
if strings.TrimSpace(fileID) == "" {
|
||||
return fmt.Errorf("--file-ids 第 %d 项不能为空", index+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
|
||||
data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值不是 JSON 对象", tool))
|
||||
}
|
||||
result, ok := response["result"]
|
||||
if !ok {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值缺少 result", tool))
|
||||
}
|
||||
return output.Success(result), nil
|
||||
}
|
||||
|
||||
func newOAAttachmentCommand() *cobra.Command {
|
||||
attachmentCmd := &cobra.Command{
|
||||
Use: "attachment",
|
||||
Short: "审批附件授权与下载链接",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
downloadURLCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "download-url",
|
||||
Short: "获取审批附件下载链接",
|
||||
Example: " dws oa approval attachment download-url --instance-id <processInstanceId> --file-id <fileId>",
|
||||
Server: "oa",
|
||||
Tool: "get_attachment_download_url",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
ResultCall: callOAAttachmentResult,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true},
|
||||
{Name: "file-id", Usage: "审批附件文件 ID (必填)", Bind: "fileId", Trim: true, Required: true, MarkRequired: true},
|
||||
{Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"},
|
||||
},
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "get_attachment_download_url",
|
||||
CanonicalPath: "oa.get_attachment_download_url",
|
||||
CLIPath: "oa approval attachment download-url",
|
||||
PrimaryCLIPath: "oa approval attachment download-url",
|
||||
},
|
||||
Description: "获取审批附件下载授权并生成临时下载链接",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件临时下载信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"downloadUri":{"type":"string","description":"带临时授权签名的附件下载链接"},"class":{"type":"string","description":"服务端响应类型标识"},"fileId":{"type":"string","description":"审批附件文件 ID"}},"required":["spaceId","agentId","downloadUri","fileId"],"additionalProperties":true}`),
|
||||
SensitivePaths: []string{"downloadUri"},
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_attachment_download_url"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取审批实例中指定附件的临时下载链接",
|
||||
UseWhen: []string{"已从审批详情获得 processInstanceId 和 fileId,需要获取附件下载链接时"},
|
||||
AvoidWhen: []string{
|
||||
"只需查看审批表单和附件元数据时使用 dws oa approval detail",
|
||||
"需要将附件真正保存到本地时不要误认为本命令会下载文件;它只返回链接",
|
||||
},
|
||||
Examples: []string{"dws oa approval attachment download-url --instance-id <processInstanceId> --file-id <fileId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"},
|
||||
{Name: "file-id", Property: "fileId", InterfaceType: "string"},
|
||||
{Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
authorizeDownloadCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "authorize-download",
|
||||
Short: "授权当前用户下载审批钉盘文件",
|
||||
Long: "批量授权当前用户下载指定的审批钉盘文件。",
|
||||
Example: ` dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`,
|
||||
Server: "oa",
|
||||
Tool: "auth_download_file",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
ResultCall: callOAAttachmentResult,
|
||||
Flags: []LeafFlag{
|
||||
{
|
||||
Name: "file-infos", Usage: "审批钉盘文件信息 JSON 数组 (必填)",
|
||||
Bind: "fileInfos", Trim: true, Required: true, MarkRequired: true,
|
||||
Transform: func(raw string) (any, error) {
|
||||
return parseOAAttachmentFileInfos(raw)
|
||||
},
|
||||
},
|
||||
},
|
||||
Constraints: []LeafConstraint{{
|
||||
Kind: corecmd.Custom, Flags: []string{"file-infos"},
|
||||
Description: "文件信息列表必须包含 1 至 10 项",
|
||||
}},
|
||||
Validate: validateOAAttachmentFileInfos,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "auth_download_file",
|
||||
CanonicalPath: "oa.auth_download_file",
|
||||
CLIPath: "oa approval attachment authorize-download",
|
||||
PrimaryCLIPath: "oa approval attachment authorize-download",
|
||||
},
|
||||
Description: "批量授权当前用户下载指定的审批钉盘文件",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"boolean","description":"是否成功为当前用户授予审批钉盘文件下载权限"}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_download_file"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "使用审批钉盘 spaceId/fileId 批量授权当前用户下载文件",
|
||||
UseWhen: []string{"已有一个或多个审批钉盘文件的 spaceId 和 fileId,需要为当前用户取得下载权限时"},
|
||||
AvoidWhen: []string{
|
||||
"需要生成单个审批附件下载链接时使用 attachment download-url",
|
||||
"需要授权在审批单内预览附件时使用 attachment authorize-preview",
|
||||
},
|
||||
Examples: []string{`dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "file-infos", Property: "fileInfos", InterfaceType: "array"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
authorizePreviewCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "authorize-preview",
|
||||
Short: "授权当前用户预览审批附件",
|
||||
Long: "批量授权当前用户预览审批单中的附件。",
|
||||
Example: " dws oa approval attachment authorize-preview --instance-id <processInstanceId> --file-ids <fileId1>,<fileId2>",
|
||||
Server: "oa",
|
||||
Tool: "auth_preview_attachment",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
ResultCall: callOAAttachmentResult,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true},
|
||||
{Name: "file-ids", Usage: "附件 ID 列表,多个用逗号分隔 (必填)", Kind: LeafStringSlice, Bind: "fileIdList", Required: true, MarkRequired: true},
|
||||
{Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"},
|
||||
},
|
||||
Constraints: []LeafConstraint{{
|
||||
Kind: corecmd.Custom, Flags: []string{"file-ids"},
|
||||
Description: "附件 ID 列表最多包含 20 项且每项不能为空",
|
||||
}},
|
||||
Validate: validateOAPreviewFileIDs,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "auth_preview_attachment",
|
||||
CanonicalPath: "oa.auth_preview_attachment",
|
||||
CLIPath: "oa approval attachment authorize-preview",
|
||||
PrimaryCLIPath: "oa approval attachment authorize-preview",
|
||||
},
|
||||
Description: "批量授权当前用户预览审批单中的附件",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件预览授权信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["spaceId","agentId"],"additionalProperties":true}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_preview_attachment"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按审批实例和附件 ID 列表授权当前用户预览附件",
|
||||
UseWhen: []string{"已有 processInstanceId 和附件 fileId 列表,需要在审批场景中批量取得预览权限时"},
|
||||
AvoidWhen: []string{
|
||||
"需要下载权限而不是预览权限时使用 attachment authorize-download",
|
||||
"需要直接获得单个附件临时下载链接时使用 attachment download-url",
|
||||
},
|
||||
Examples: []string{"dws oa approval attachment authorize-preview --instance-id <processInstanceId> --file-ids <fileId1>,<fileId2>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"},
|
||||
{Name: "file-ids", Property: "fileIdList", InterfaceType: "array"},
|
||||
{Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd)
|
||||
return attachmentCmd
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws oa — OA 审批
|
||||
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
|
||||
@@ -49,7 +329,8 @@ func oaFormValues(raw string) ([]map[string]string, error) {
|
||||
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
|
||||
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
|
||||
// get_inst_revert_activities, get_process_schema, forecast_process,
|
||||
// start_process_instance
|
||||
// start_process_instance, get_attachment_download_url, auth_download_file,
|
||||
// auth_preview_attachment
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newOaCommand() *cobra.Command {
|
||||
@@ -58,9 +339,10 @@ func newOaCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "oa",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送与评论",
|
||||
AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送、评论与附件授权",
|
||||
UseWhen: []string{
|
||||
"查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时",
|
||||
"获取审批附件下载链接,或为当前用户授权下载、预览审批附件时",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume",
|
||||
@@ -70,7 +352,7 @@ func newOaCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "oa",
|
||||
Short: "OA 审批 / 同意 / 拒绝 / 撤销",
|
||||
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表。`,
|
||||
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -1376,6 +1658,7 @@ func newOaCommand() *cobra.Command {
|
||||
approvalForecastCmd,
|
||||
approvalCreateCmd,
|
||||
)
|
||||
approvalCmd.AddCommand(newOAAttachmentCommand())
|
||||
root.AddCommand(approvalCmd)
|
||||
|
||||
return root
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToolCaller, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "oa"})
|
||||
InitDeps(caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
deps.Out.errW = io.Discard
|
||||
|
||||
cmd := newOaCommand()
|
||||
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
|
||||
cmd.PersistentFlags().String("format", caller.Format(), "输出格式")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentDownloadURLPreservesSignedQuery(t *testing.T) {
|
||||
const response = `{"result":{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"},"success":true}`
|
||||
commandArgs := []string{
|
||||
"approval", "attachment", "download-url",
|
||||
"--instance-id", "instance-1",
|
||||
"--file-id", "file-1",
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"processInstanceId": "instance-1",
|
||||
"fileId": "file-1",
|
||||
}
|
||||
|
||||
t.Run("json", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}}
|
||||
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
if caller.server != "oa" || caller.tool != "get_attachment_download_url" {
|
||||
t.Fatalf("called %s/%s, want oa/get_attachment_download_url", caller.server, caller.tool)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("args = %#v, want %#v", caller.args, wantArgs)
|
||||
}
|
||||
if !strings.Contains(stdout, "&OSSAccessKeyId=") || !strings.Contains(stdout, "&Signature=") {
|
||||
t.Fatalf("stdout does not preserve signed query separators: %q", stdout)
|
||||
}
|
||||
if strings.Contains(stdout, `\u0026`) {
|
||||
t.Fatalf("stdout contains escaped query separator: %q", stdout)
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
|
||||
t.Fatalf("decode unified output: %v", err)
|
||||
}
|
||||
if envelope["ok"] != true || envelope["outcome"] != "success" {
|
||||
t.Fatalf("unified envelope = %#v", envelope)
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]any)
|
||||
if !ok || !strings.Contains(data["downloadUri"].(string), "&Signature=") {
|
||||
t.Fatalf("unified data = %#v", envelope["data"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw compatibility", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "raw", steps: []scriptedToolStep{{text: response}}}
|
||||
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
const want = `{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"}` + "\n"
|
||||
if stdout != want {
|
||||
t.Fatalf("stdout = %q, want %q", stdout, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentProjectsUnifiedBusinessData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
response string
|
||||
wantData any
|
||||
}{
|
||||
{
|
||||
name: "download authorization",
|
||||
args: []string{"approval", "attachment", "authorize-download", "--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`},
|
||||
response: `{"result":true,"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`,
|
||||
wantData: true,
|
||||
},
|
||||
{
|
||||
name: "preview authorization",
|
||||
args: []string{"approval", "attachment", "authorize-preview", "--instance-id", "instance-1", "--file-ids", "file-1"},
|
||||
response: `{"result":{"spaceId":27827223951,"agentId":4115627346,"class":"com.dingtalk.bpms.oapi.vo.AppSpaceResponse"},"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`,
|
||||
wantData: map[string]any{
|
||||
"spaceId": float64(27827223951), "agentId": float64(4115627346),
|
||||
"class": "com.dingtalk.bpms.oapi.vo.AppSpaceResponse",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: test.response}}}
|
||||
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
|
||||
t.Fatalf("decode unified output: %v", err)
|
||||
}
|
||||
if envelope["ok"] != true || envelope["outcome"] != "success" || !reflect.DeepEqual(envelope["data"], test.wantData) {
|
||||
t.Fatalf("unified envelope = %#v, want data %#v", envelope, test.wantData)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentRejectsSuccessWithoutResult(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`}}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "authorize-download",
|
||||
"--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "缺少 result") {
|
||||
t.Fatalf("execute error = %v, want missing result", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentRejectsInvalidToolResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
step scriptedToolStep
|
||||
want string
|
||||
}{
|
||||
{name: "non-object response", step: scriptedToolStep{text: `[]`}, want: "不是 JSON 对象"},
|
||||
{name: "tool error", step: scriptedToolStep{err: errors.New("tool unavailable")}, want: "tool unavailable"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{test.step}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "authorize-download",
|
||||
"--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("execute error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentPayloads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
check func(*testing.T, map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "download URL",
|
||||
args: []string{
|
||||
"approval", "attachment", "download-url",
|
||||
"--instance-id", "instance-1",
|
||||
"--file-id", "file-1",
|
||||
"--with-comment-attachment=false",
|
||||
},
|
||||
wantTool: "get_attachment_download_url",
|
||||
check: func(t *testing.T, got map[string]any) {
|
||||
t.Helper()
|
||||
if got["processInstanceId"] != "instance-1" || got["fileId"] != "file-1" {
|
||||
t.Fatalf("download identifiers = %#v", got)
|
||||
}
|
||||
if value, ok := got["withCommentAttachment"].(bool); !ok || value {
|
||||
t.Fatalf("withCommentAttachment = %#v, want explicit false", got["withCommentAttachment"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "download authorization",
|
||||
args: []string{
|
||||
"approval", "attachment", "authorize-download",
|
||||
"--file-infos", `[{"spaceId":27827223951,"fileId":"file-2"}]`,
|
||||
},
|
||||
wantTool: "auth_download_file",
|
||||
check: func(t *testing.T, got map[string]any) {
|
||||
t.Helper()
|
||||
infos, ok := got["fileInfos"].([]map[string]any)
|
||||
if !ok || len(infos) != 1 {
|
||||
t.Fatalf("fileInfos = %#v", got["fileInfos"])
|
||||
}
|
||||
if infos[0]["spaceId"] != json.Number("27827223951") || infos[0]["fileId"] != "file-2" {
|
||||
t.Fatalf("fileInfos[0] = %#v", infos[0])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preview authorization",
|
||||
args: []string{
|
||||
"approval", "attachment", "authorize-preview",
|
||||
"--instance-id", "instance-2",
|
||||
"--file-ids", "file-3,file-4",
|
||||
"--with-comment-attachment",
|
||||
},
|
||||
wantTool: "auth_preview_attachment",
|
||||
check: func(t *testing.T, got map[string]any) {
|
||||
t.Helper()
|
||||
if got["processInstanceId"] != "instance-2" || got["withCommentAttachment"] != true {
|
||||
t.Fatalf("preview scalar payload = %#v", got)
|
||||
}
|
||||
ids, ok := got["fileIdList"].([]string)
|
||||
if !ok || len(ids) != 2 || ids[0] != "file-3" || ids[1] != "file-4" {
|
||||
t.Fatalf("fileIdList = %#v", got["fileIdList"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "optional boolean omitted",
|
||||
args: []string{
|
||||
"approval", "attachment", "download-url",
|
||||
"--instance-id", "instance-3",
|
||||
"--file-id", "file-5",
|
||||
},
|
||||
wantTool: "get_attachment_download_url",
|
||||
check: func(t *testing.T, got map[string]any) {
|
||||
t.Helper()
|
||||
if _, exists := got["withCommentAttachment"]; exists {
|
||||
t.Fatalf("optional withCommentAttachment should be omitted: %#v", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}}
|
||||
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...); err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
if caller.server != "oa" || caller.tool != test.wantTool {
|
||||
t.Fatalf("called %s/%s, want oa/%s", caller.server, caller.tool, test.wantTool)
|
||||
}
|
||||
test.check(t, caller.args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentRejectsInvalidFileInfos(t *testing.T) {
|
||||
valid := `{"spaceId":27827223951,"fileId":"file-1"}`
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "malformed JSON", raw: `[{`},
|
||||
{name: "null", raw: `null`},
|
||||
{name: "not array", raw: `{}`},
|
||||
{name: "empty array", raw: `[]`},
|
||||
{name: "more than ten", raw: `[` + strings.Repeat(valid+`,`, 10) + valid + `]`},
|
||||
{name: "missing space ID", raw: `[{"fileId":"file-1"}]`},
|
||||
{name: "string space ID", raw: `[{"spaceId":"27827223951","fileId":"file-1"}]`},
|
||||
{name: "missing file ID", raw: `[{"spaceId":27827223951}]`},
|
||||
{name: "numeric file ID", raw: `[{"spaceId":27827223951,"fileId":232271651278}]`},
|
||||
{name: "blank file ID", raw: `[{"spaceId":27827223951,"fileId":" "}]`},
|
||||
{name: "unknown property", raw: `[{"spaceId":27827223951,"fileId":"file-1","extra":true}]`},
|
||||
{name: "trailing JSON", raw: `[` + valid + `] {}`},
|
||||
{name: "malformed trailing JSON", raw: `[` + valid + `] {`},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller,
|
||||
"approval", "attachment", "authorize-download",
|
||||
"--file-infos", test.raw,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("fileInfos %q unexpectedly succeeded", test.raw)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid fileInfos made %d MCP call(s)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "separator only", raw: `,`},
|
||||
{name: "blank item", raw: `file-1, ,file-2`},
|
||||
{name: "more than twenty", raw: strings.Repeat("file,", 20) + "file"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller,
|
||||
"approval", "attachment", "authorize-preview",
|
||||
"--instance-id", "instance-1",
|
||||
"--file-ids", test.raw,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("file IDs %q unexpectedly succeeded", test.raw)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid file IDs made %d MCP call(s)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// 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.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type nativePrimaryParamCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type nativePrimaryParamCaller struct {
|
||||
calls []nativePrimaryParamCall
|
||||
}
|
||||
|
||||
func (c *nativePrimaryParamCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, nativePrimaryParamCall{server: server, tool: tool, args: copied})
|
||||
|
||||
switch tool {
|
||||
case "init_conversation_file_upload", "init_todo_file_upload":
|
||||
return textToolResult(`{"resourceUrl":"https://upload.invalid/file","uploadKey":"upload-key"}`), nil
|
||||
case "commit_conversation_file_upload":
|
||||
fileName, _ := args["fileName"].(string)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"dentryId": 123,
|
||||
"spaceId": 456,
|
||||
"downloadUrl": "https://download.invalid/" + fileName,
|
||||
})
|
||||
return textToolResult(string(payload)), nil
|
||||
case "commit_todo_file_upload":
|
||||
return textToolResult(`{"dentryId":123,"spaceId":456}`), nil
|
||||
default:
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (*nativePrimaryParamCaller) Format() string { return "json" }
|
||||
func (*nativePrimaryParamCaller) DryRun() bool { return false }
|
||||
func (*nativePrimaryParamCaller) Fields() string { return "" }
|
||||
func (*nativePrimaryParamCaller) JQ() string { return "" }
|
||||
|
||||
func (c *nativePrimaryParamCaller) lastCall(t *testing.T, tool string) nativePrimaryParamCall {
|
||||
t.Helper()
|
||||
for i := len(c.calls) - 1; i >= 0; i-- {
|
||||
if c.calls[i].tool == tool {
|
||||
return c.calls[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("tool %q not called; calls = %#v", tool, c.calls)
|
||||
return nativePrimaryParamCall{}
|
||||
}
|
||||
|
||||
func executeNativePrimaryTodo(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "todo"}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
cmd := newTodoCommand()
|
||||
if cmd.PersistentFlags().Lookup("yes") == nil {
|
||||
cmd.PersistentFlags().Bool("yes", false, "confirm execution")
|
||||
}
|
||||
if cmd.PersistentFlags().Lookup("dry-run") == nil {
|
||||
cmd.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func findNativePrimaryLeaf(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
leaf, remaining, err := root.Find(path)
|
||||
if err != nil {
|
||||
t.Fatalf("find %v: %v", path, err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("find %v left arguments %v", path, remaining)
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
func assertNativePrimaryFlagPair(t *testing.T, cmd *cobra.Command, primary, legacy string) {
|
||||
t.Helper()
|
||||
primaryFlag := cmd.Flags().Lookup(primary)
|
||||
if primaryFlag == nil || primaryFlag.Hidden {
|
||||
t.Fatalf("%s --%s = %#v, want visible Primary", cmd.CommandPath(), primary, primaryFlag)
|
||||
}
|
||||
legacyFlag := cmd.Flags().Lookup(legacy)
|
||||
if legacyFlag == nil || !legacyFlag.Hidden {
|
||||
t.Fatalf("%s --%s = %#v, want hidden compatibility alias", cmd.CommandPath(), legacy, legacyFlag)
|
||||
}
|
||||
if got := legacyFlag.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != primary {
|
||||
t.Fatalf("%s --%s alias_of = %#v, want %q", cmd.CommandPath(), legacy, got, primary)
|
||||
}
|
||||
if got := legacyFlag.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("%s --%s alias_origin = %#v, want %q", cmd.CommandPath(), legacy, got, runtimeannotate.FlagAliasOriginCorecmdV1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamFlagSurfaces(t *testing.T) {
|
||||
aisearch := newAisearchCommand()
|
||||
assertNativePrimaryFlagPair(t, aisearch, "query", "keyword")
|
||||
person := findNativePrimaryLeaf(t, aisearch, "person")
|
||||
assertNativePrimaryFlagPair(t, person, "query", "keyword")
|
||||
for _, cmd := range []*cobra.Command{aisearch, person} {
|
||||
if got := cmd.Flags().Lookup("keyword").Shorthand; got != "w" {
|
||||
t.Fatalf("%s --keyword shorthand = %q, want preserved -w", cmd.CommandPath(), got)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("query").Shorthand; got != "" {
|
||||
t.Fatalf("%s --query shorthand = %q, want none", cmd.CommandPath(), got)
|
||||
}
|
||||
}
|
||||
|
||||
chat := newChatCommand()
|
||||
send := findNativePrimaryLeaf(t, chat, "message", "send")
|
||||
assertNativePrimaryFlagPair(t, send, "content", "text")
|
||||
assertNativePrimaryFlagPair(t, send, "file", "file-path")
|
||||
sendByWebhook := findNativePrimaryLeaf(t, chat, "message", "send-by-webhook")
|
||||
assertNativePrimaryFlagPair(t, sendByWebhook, "content", "text")
|
||||
sendByBot := findNativePrimaryLeaf(t, chat, "message", "send-by-bot")
|
||||
for _, name := range []string{"text", "file-path"} {
|
||||
flag := sendByBot.Flags().Lookup(name)
|
||||
if flag == nil || flag.Hidden {
|
||||
t.Fatalf("%s --%s = %#v, want unchanged visible Primary", sendByBot.CommandPath(), name, flag)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"content", "file"} {
|
||||
if flag := sendByBot.Flags().Lookup(name); flag != nil && !flag.Hidden {
|
||||
t.Fatalf("%s --%s = %#v, pending migration must not become visible", sendByBot.CommandPath(), name, flag)
|
||||
}
|
||||
}
|
||||
reply := findNativePrimaryLeaf(t, chat, "message", "reply")
|
||||
assertNativePrimaryFlagPair(t, reply, "group", "conversation-id")
|
||||
assertNativePrimaryFlagPair(t, reply, "content", "text")
|
||||
|
||||
doc := newDocCommand()
|
||||
assertNativePrimaryFlagPair(t, findNativePrimaryLeaf(t, doc, "block", "insert"), "content", "text")
|
||||
assertNativePrimaryFlagPair(t, findNativePrimaryLeaf(t, doc, "block", "update"), "content", "text")
|
||||
|
||||
todo := newTodoCommand()
|
||||
assertNativePrimaryFlagPair(t, findNativePrimaryLeaf(t, todo, "task", "add-attachment"), "file", "file-path")
|
||||
}
|
||||
|
||||
type nativePrimaryParamSpelling struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}
|
||||
|
||||
func nativePrimaryParamSpellings(primary, legacy string) []nativePrimaryParamSpelling {
|
||||
return []nativePrimaryParamSpelling{
|
||||
{name: "legacy only", args: []string{"--" + legacy, "legacy-value"}, want: "legacy-value"},
|
||||
{name: "primary only", args: []string{"--" + primary, "primary-value"}, want: "primary-value"},
|
||||
{name: "both legacy wins", args: []string{"--" + primary, "primary-value", "--" + legacy, "legacy-value"}, want: "legacy-value"},
|
||||
}
|
||||
}
|
||||
|
||||
func nativePrimaryJSONField(t *testing.T, raw any, field string) string {
|
||||
t.Helper()
|
||||
encoded, ok := raw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("payload = %#v, want JSON string", raw)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(encoded), &payload); err != nil {
|
||||
t.Fatalf("decode payload %q: %v", encoded, err)
|
||||
}
|
||||
value, _ := payload[field].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func nativePrimaryParagraphText(t *testing.T, call nativePrimaryParamCall) string {
|
||||
t.Helper()
|
||||
element, ok := call.args["element"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s element = %#v", call.tool, call.args["element"])
|
||||
}
|
||||
paragraph, ok := element["paragraph"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s paragraph = %#v", call.tool, element["paragraph"])
|
||||
}
|
||||
text, _ := paragraph["text"].(string)
|
||||
return text
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamTextPayloadCompatibility(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
t.Run("chat message send text to content", func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "send", "--group", "cid", "--title", "fixed-title"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_personal_message")
|
||||
if got := nativePrimaryJSONField(t, call.args["content"], "text"); got != spelling.want {
|
||||
t.Fatalf("content.text = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chat message send-by-webhook text to content", func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "send-by-webhook", "--token", "token", "--title", "fixed-title"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_message_by_custom_robot")
|
||||
if got, _ := call.args["text"].(string); got != spelling.want {
|
||||
t.Fatalf("text = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chat message reply conversation-id to group", func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("group", "conversation-id") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "reply", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--content", "fixed-content"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_personal_message")
|
||||
if got, _ := call.args["openConversationId"].(string); got != spelling.want {
|
||||
t.Fatalf("openConversationId = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chat message reply text to content", func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "reply", "--group", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_personal_message")
|
||||
if got := nativePrimaryJSONField(t, call.args["content"], "content"); got != spelling.want {
|
||||
t.Fatalf("reply content = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
for _, command := range []struct {
|
||||
name string
|
||||
path []string
|
||||
tool string
|
||||
}{
|
||||
{name: "doc block insert text to content", path: []string{"block", "insert", "--node", "node"}, tool: "insert_document_block"},
|
||||
{name: "doc block update text to content", path: []string{"block", "update", "--node", "node", "--block-id", "block"}, tool: "update_document_block"},
|
||||
} {
|
||||
command := command
|
||||
t.Run(command.name, func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := append(append([]string(nil), command.path...), spelling.args...)
|
||||
if err := runDocCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, command.tool)
|
||||
if got := nativePrimaryParagraphText(t, call); got != spelling.want {
|
||||
t.Fatalf("paragraph text = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamFilePayloadCompatibility(t *testing.T) {
|
||||
previousDeps, previousPut, previousArgs := deps, httpPutFile, os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
httpPutFile = previousPut
|
||||
os.Args = previousArgs
|
||||
})
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
|
||||
dir := t.TempDir()
|
||||
legacyPath := filepath.Join(dir, "legacy-file.txt")
|
||||
primaryPath := filepath.Join(dir, "primary-file.txt")
|
||||
if err := os.WriteFile(legacyPath, []byte("legacy"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(primaryPath, []byte("primary"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spellings := []nativePrimaryParamSpelling{
|
||||
{name: "legacy only", args: []string{"--file-path", legacyPath}, want: filepath.Base(legacyPath)},
|
||||
{name: "primary only", args: []string{"--file", primaryPath}, want: filepath.Base(primaryPath)},
|
||||
{name: "both legacy wins", args: []string{"--file", primaryPath, "--file-path", legacyPath}, want: filepath.Base(legacyPath)},
|
||||
}
|
||||
|
||||
t.Run("chat message send file-path to file", func(t *testing.T) {
|
||||
os.Args = []string{"dws", "chat"}
|
||||
for _, spelling := range spellings {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "send", "--group", "cid", "--msg-type", "file"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_personal_message")
|
||||
if got := nativePrimaryJSONField(t, call.args["content"], "fileName"); got != spelling.want {
|
||||
t.Fatalf("fileName = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("todo task add-attachment file-path to file", func(t *testing.T) {
|
||||
os.Args = []string{"dws", "todo"}
|
||||
for _, spelling := range spellings {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"task", "add-attachment", "--task-id", "42"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := executeNativePrimaryTodo(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "add_todo_attachment")
|
||||
request, ok := call.args["todoAttachmentAddRequest"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("request = %#v", call.args)
|
||||
}
|
||||
attachments, ok := request["attachmentList"].([]any)
|
||||
if !ok || len(attachments) != 1 {
|
||||
t.Fatalf("attachments = %#v", request["attachmentList"])
|
||||
}
|
||||
attachment, ok := attachments[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("attachment = %#v", attachments[0])
|
||||
}
|
||||
if got, _ := attachment["fileName"].(string); got != spelling.want {
|
||||
t.Fatalf("fileName = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func nativePrimaryErrorMentionsFlag(message, name string) bool {
|
||||
return strings.Contains(message, "--"+name) || strings.Contains(message, fmt.Sprintf("%q", name))
|
||||
}
|
||||
|
||||
func assertNativePrimaryErrorHint(t *testing.T, err error, primary, legacy string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("missing --%s returned nil", primary)
|
||||
}
|
||||
message := err.Error()
|
||||
if !nativePrimaryErrorMentionsFlag(message, primary) {
|
||||
t.Fatalf("error %q does not recommend --%s", message, primary)
|
||||
}
|
||||
if nativePrimaryErrorMentionsFlag(message, legacy) {
|
||||
t.Fatalf("error %q still recommends legacy --%s", message, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamMissingErrorsRecommendPrimaryOnly(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
assertNativePrimaryErrorHint(t, executeFilterCoverage(t, newAisearchCommand(), "person"), "query", "keyword")
|
||||
|
||||
os.Args = []string{"dws", "chat"}
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "send", "--group", "cid"),
|
||||
"content", "text")
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "send-by-webhook", "--token", "token", "--title", "title"),
|
||||
"content", "text")
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "reply", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--content", "body"),
|
||||
"group", "conversation-id")
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "reply", "--group", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID),
|
||||
"content", "text")
|
||||
|
||||
os.Args = []string{"dws", "doc"}
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runDocCoverageCommand(t, &nativePrimaryParamCaller{}, "block", "insert", "--node", "node"),
|
||||
"content", "text")
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runDocCoverageCommand(t, &nativePrimaryParamCaller{}, "block", "update", "--node", "node", "--block-id", "block"),
|
||||
"content", "text")
|
||||
|
||||
os.Args = []string{"dws", "todo"}
|
||||
assertNativePrimaryErrorHint(t,
|
||||
executeTodoEdge(t, &scriptedToolCaller{}, "task", "add-attachment", "--task-id", "42"),
|
||||
"file", "file-path")
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamPromotionErrorsPropagate(t *testing.T) {
|
||||
chat := newChatCommand()
|
||||
assertPromotionError := func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil || !strings.Contains(err.Error(), "trying to get string value of flag of type bool") {
|
||||
t.Fatalf("promotion error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("webhook RunE", func(t *testing.T) {
|
||||
target := findNativePrimaryLeaf(t, chat, "message", "send-by-webhook")
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("content", "", "")
|
||||
cmd.Flags().Bool("text", false, "")
|
||||
if err := cmd.Flags().Set("text", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPromotionError(t, target.RunE(cmd, nil))
|
||||
})
|
||||
|
||||
reply := findNativePrimaryLeaf(t, chat, "message", "reply")
|
||||
newInvalidGroupCommand := func(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("group", "", "")
|
||||
cmd.Flags().Bool("conversation-id", false, "")
|
||||
if err := cmd.Flags().Set("conversation-id", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
t.Run("reply PreRunE group", func(t *testing.T) {
|
||||
assertPromotionError(t, reply.PreRunE(newInvalidGroupCommand(t), nil))
|
||||
})
|
||||
|
||||
t.Run("reply RunE group", func(t *testing.T) {
|
||||
assertPromotionError(t, reply.RunE(newInvalidGroupCommand(t), nil))
|
||||
})
|
||||
|
||||
t.Run("reply RunE content", func(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("group", "cid", "")
|
||||
cmd.Flags().String("conversation-id", "", "")
|
||||
cmd.Flags().String("content", "", "")
|
||||
cmd.Flags().Bool("text", false, "")
|
||||
if err := cmd.Flags().Set("text", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPromotionError(t, reply.RunE(cmd, nil))
|
||||
})
|
||||
}
|
||||
@@ -304,5 +304,6 @@ func newSheetCommentCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd)
|
||||
commentCmd.AddCommand(newCommentBaseCommands("sheet")...)
|
||||
return commentCmd
|
||||
}
|
||||
|
||||
+20
-10
@@ -11,11 +11,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -1017,14 +1017,20 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
Short: "上传待办附件",
|
||||
Long: `上传待办附件
|
||||
⚠️ 重要:该接口会上传文件到附件,不可用于测试或试探性调用。调用前必须确认待办存在。`,
|
||||
Example: ` dws todo task add-attachment --task-id <taskId> --file-path <filePath>
|
||||
Example: ` dws todo task add-attachment --task-id <taskId> --file <filePath>
|
||||
# 查询 taskId: dws todo task list`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "task-id", "file-path"); err != nil {
|
||||
return err
|
||||
filePath := flagOrFallback(cmd, "file-path", "file")
|
||||
taskID := mustGetFlag(cmd, "task-id")
|
||||
if taskID == "" && filePath == "" {
|
||||
return validateRequiredFlags(cmd, "task-id", "file")
|
||||
}
|
||||
if taskID == "" {
|
||||
return validateRequiredFlags(cmd, "task-id")
|
||||
}
|
||||
if filePath == "" {
|
||||
return validateRequiredFlags(cmd, "file")
|
||||
}
|
||||
|
||||
filePath, _ := cmd.Flags().GetString("file-path")
|
||||
meta, err := buildTodoLocalFileMeta(filePath, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1092,8 +1098,8 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
"要添加执行人/参与人/提醒时改用对应 add 命令",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws todo task add-attachment --task-id <taskId> --file-path /path/to/file.pdf",
|
||||
"dws todo task add-attachment --task-id <taskId> --file-path /path/to/file.pdf --format json",
|
||||
"dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf",
|
||||
"dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
@@ -1247,7 +1253,11 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
|
||||
todoTaskListSubCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskListAttachmentCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskAddAttachment.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskAddAttachment.Flags().String("file-path", "", "本地文件路径(必填)")
|
||||
corecmd.RegisterFlags(todoTaskAddAttachment, []corecmd.FlagSpec{{
|
||||
Name: "file",
|
||||
Usage: "本地文件路径(必填)",
|
||||
Aliases: []string{"file-path"},
|
||||
}})
|
||||
todoTaskRemoveAttachmentCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
|
||||
todoTaskRemoveAttachmentCmd.Flags().String("attachment-id", "", "待办附件 ID(必填)")
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -78,95 +79,99 @@ var EventList = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00),默认今天 00:00"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601,默认今天 23:59"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start;默认今天 00:00"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start;默认今天 23:59"},
|
||||
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID (默认 primary 主日历)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一次返回的 nextCursor)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回条数 (默认 100,最大 100)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回条数(服务端默认 100);limit 必须在 1-100 之间"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws calendar +agenda`,
|
||||
`dws calendar +agenda --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --limit 50`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
now := time.Now()
|
||||
if rt.Changed("start") {
|
||||
ms, err := parseMillis("start", rt.Str("start"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["startTime"] = ms
|
||||
} else {
|
||||
params["startTime"] = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
|
||||
}
|
||||
if rt.Changed("end") {
|
||||
ms, err := parseMillis("end", rt.Str("end"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["endTime"] = ms
|
||||
} else {
|
||||
params["endTime"] = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
|
||||
}
|
||||
if rt.Changed("calendar-id") {
|
||||
params["calendarId"] = rt.Str("calendar-id")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["limit"] = rt.Int("limit")
|
||||
params, err := calendarAgendaParams(rt, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events := eventListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(events), "events": events})
|
||||
events, page, err := eventListProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]any{"count": len(events), "events": events}
|
||||
return outputCalendarPage(rt, out, page)
|
||||
},
|
||||
}
|
||||
|
||||
// calendarAgendaParams is the explicit adapter from the published composite
|
||||
// Shortcut properties (start/end) to list_calendar_events RPC properties
|
||||
// (startTime/endTime). The public Schema property names predate this adapter
|
||||
// and remain stable for backwards compatibility.
|
||||
func calendarAgendaParams(rt *shortcut.RuntimeContext, now time.Time) (map[string]any, error) {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("start") {
|
||||
ms, err := parseMillis("start", rt.Str("start"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params["startTime"] = ms
|
||||
} else {
|
||||
params["startTime"] = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
|
||||
}
|
||||
if rt.Changed("end") {
|
||||
ms, err := parseMillis("end", rt.Str("end"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params["endTime"] = ms
|
||||
} else {
|
||||
params["endTime"] = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
|
||||
}
|
||||
if rt.Changed("calendar-id") {
|
||||
params["calendarId"] = rt.Str("calendar-id")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["limit"] = rt.Int("limit")
|
||||
}
|
||||
if params["endTime"].(int64) <= params["startTime"].(int64) {
|
||||
return nil, fmt.Errorf("--end 必须晚于 --start")
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// eventListProject reshapes the raw list_calendar_events response into a clean,
|
||||
// stable event list (eventId/summary/start/end/status/location) — the
|
||||
// the clean output projection applied to every list command. The
|
||||
// list container and each field are probed defensively across candidate keys,
|
||||
// since event payloads may nest under result/data/list/items with aliases.
|
||||
func eventListProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
func eventListProject(data map[string]any) ([]map[string]any, calendarPageEvidence, error) {
|
||||
items, container, err := requireCalendarCollection(data, "calendar/list_calendar_events", "events")
|
||||
if err != nil {
|
||||
return nil, calendarPageEvidence{}, err
|
||||
}
|
||||
raw := eventListContainer(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, ok := eventListFirst(m, "eventId", "event_id", "id"); ok {
|
||||
row["eventId"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "summary", "title", "name"); ok {
|
||||
row["summary"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "start", "startTime", "start_time", "startDateTime", "start_date_time"); ok {
|
||||
row["start"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "end", "endTime", "end_time", "endDateTime", "end_date_time"); ok {
|
||||
row["end"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "status", "eventStatus", "event_status", "responseStatus", "response_status"); ok {
|
||||
row["status"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "location", "locationName", "location_name"); ok {
|
||||
row["location"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
rows, err := projectCalendarRows(items, "calendar/list_calendar_events", map[string][]string{
|
||||
"eventId": {"eventId", "event_id", "id"},
|
||||
"summary": {"summary", "title", "name"},
|
||||
"start": {"start", "startTime", "start_time", "startDateTime", "start_date_time"},
|
||||
"end": {"end", "endTime", "end_time", "endDateTime", "end_date_time"},
|
||||
"status": {"status", "eventStatus", "event_status", "responseStatus", "response_status"},
|
||||
"location": {"location", "locationName", "location_name"},
|
||||
}, "eventId")
|
||||
if err != nil {
|
||||
return nil, calendarPageEvidence{}, err
|
||||
}
|
||||
return out
|
||||
page, err := calendarPagination(container, "calendar/list_calendar_events")
|
||||
if err == nil && !page.Known {
|
||||
err = calendarResponseError("calendar/list_calendar_events", "missing_pagination", "日程列表响应缺少 hasMore/nextCursor 分页证据,不能判断结果是否完整")
|
||||
}
|
||||
return rows, page, err
|
||||
}
|
||||
|
||||
// eventListContainer locates the event slice across candidate wrapper keys,
|
||||
@@ -257,7 +262,10 @@ var AttendeeList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attendees := attendeeListProject(data)
|
||||
attendees, err := attendeeListProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(attendees), "attendees": attendees})
|
||||
},
|
||||
}
|
||||
@@ -267,32 +275,17 @@ var AttendeeList = shortcut.Shortcut{
|
||||
// the clean output projection applied to every list command.
|
||||
// The list container and each field are probed defensively across candidate keys,
|
||||
// since participant payloads may nest under result/data/list/items with aliases.
|
||||
func attendeeListProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
func attendeeListProject(data map[string]any) ([]map[string]any, error) {
|
||||
items, _, err := requireCalendarCollection(data, "calendar/get_calendar_participants", "attendees", "participants", "items", "list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := attendeeListContainer(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, ok := attendeeFirst(m, "displayName", "display_name", "name", "userName", "user_name", "nick", "nickName"); ok {
|
||||
row["displayName"] = v
|
||||
}
|
||||
if v, ok := attendeeFirst(m, "userId", "user_id", "id", "staffId", "staff_id", "unionId", "union_id"); ok {
|
||||
row["userId"] = v
|
||||
}
|
||||
if v, ok := attendeeFirst(m, "responseStatus", "response_status", "status", "attendeeStatus", "attendee_status", "responseType", "response"); ok {
|
||||
row["responseStatus"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return projectCalendarRows(items, "calendar/get_calendar_participants", map[string][]string{
|
||||
"displayName": {"displayName", "display_name", "name", "userName", "user_name", "nick", "nickName"},
|
||||
"userId": {"userId", "user_id", "id", "staffId", "staff_id", "unionId", "union_id"},
|
||||
"responseStatus": {"responseStatus", "response_status", "status", "attendeeStatus", "attendee_status", "responseType", "response"},
|
||||
"self": {"self"},
|
||||
}, "displayName", "userId")
|
||||
}
|
||||
|
||||
// attendeeListContainer locates the participant slice across candidate wrapper
|
||||
@@ -375,7 +368,10 @@ var RoomSearch = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rooms := roomSearchProject(data)
|
||||
rooms, err := roomSearchProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(rooms), "rooms": rooms})
|
||||
},
|
||||
}
|
||||
@@ -385,35 +381,17 @@ var RoomSearch = shortcut.Shortcut{
|
||||
// the framework applies to every list command. The list container and each field
|
||||
// are probed defensively across candidate keys, since room payloads may nest
|
||||
// under result/data/list/items with aliases.
|
||||
func roomSearchProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
func roomSearchProject(data map[string]any) ([]map[string]any, error) {
|
||||
items, _, err := requireCalendarCollection(data, "calendar/search_rooms", "rooms", "roomList", "meetingRooms", "items", "list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := roomSearchContainer(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, ok := roomSearchFirst(m, "roomId", "room_id", "id"); ok {
|
||||
row["roomId"] = v
|
||||
}
|
||||
if v, ok := roomSearchFirst(m, "roomName", "room_name", "name", "summary"); ok {
|
||||
row["roomName"] = v
|
||||
}
|
||||
if v, ok := roomSearchFirst(m, "capacity", "seats", "seatCount", "seat_count"); ok {
|
||||
row["capacity"] = v
|
||||
}
|
||||
if v, ok := roomSearchFirst(m, "location", "floor", "building", "address"); ok {
|
||||
row["location"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return projectCalendarRows(items, "calendar/search_rooms", map[string][]string{
|
||||
"roomId": {"roomId", "room_id", "id"},
|
||||
"roomName": {"roomName", "room_name", "name", "summary"},
|
||||
"capacity": {"capacity", "seats", "seatCount", "seat_count"},
|
||||
"location": {"location", "floor", "building", "address"},
|
||||
}, "roomId")
|
||||
}
|
||||
|
||||
// roomSearchContainer locates the room slice across candidate wrapper keys,
|
||||
@@ -455,21 +433,48 @@ var RoomFind = shortcut.Shortcut{
|
||||
Command: "+room-find",
|
||||
Product: "calendar",
|
||||
Description: "按时间段搜索可用会议室(不传时间默认当前起 1 小时)",
|
||||
Intent: "当你要在某个时间段找一间空闲会议室开会时使用;传入 --start/--end 时间段(须为未来时间,缺省为当前起 1 小时),可加 --available 只看空闲、--group-id/--room-name 缩小范围,返回该时段的会议室及其可用性和 roomId,便于据此 +room-add 预定。",
|
||||
Intent: "当你要在一个未来时间段寻找当前可预定的会议室时使用;--group-id/--room-name 可缩小范围,显式返回当前页和页码信息。该命令只查询单个时间段,后端不支持 Lark 风格多 slot、城市/楼宇/容量联合筛选。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: calendarReadSafety(),
|
||||
Contract: calendarContract(
|
||||
"+room-find", "按时间段严格搜索可用会议室",
|
||||
"需要按一个明确的未来时间段查询可用会议室,并保留真实页码信息时使用。",
|
||||
[]string{"只按名称查会议室而不检查可用性时改用 calendar +room-search", "要预订会议室时先取得 roomId,再使用原子 room add"},
|
||||
[]string{`dws calendar +room-find --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"`},
|
||||
calendarCollectionResult("rooms", "严格校验的可用会议室当前页"), nil,
|
||||
contract.ParamDecl{Name: "start", Property: "startTime"},
|
||||
contract.ParamDecl{Name: "end", Property: "endTime"},
|
||||
contract.ParamDecl{Name: "group-id", Property: "groupId"},
|
||||
contract.ParamDecl{Name: "room-name", Property: "roomName"},
|
||||
contract.ParamDecl{Name: "limit", Property: "pageSize"},
|
||||
contract.ParamDecl{Name: "page", Property: "pageIndex"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (必须是未来时间)"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601"},
|
||||
{Name: "available", Type: shortcut.FlagBool, Desc: "仅返回可用会议室"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start"},
|
||||
{Name: "available", Type: shortcut.FlagBool, Desc: "仅返回可用会议室;保留已发布参数兼容性"},
|
||||
{Name: "group-id", Type: shortcut.FlagString, Desc: "会议室分组 ID"},
|
||||
{Name: "room-name", Type: shortcut.FlagString, Desc: "会议室名称过滤"},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页条数 (pageSize)"},
|
||||
{Name: "page", Type: shortcut.FlagString, Desc: "页码 (pageIndex,从 0 开始)"},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页条数 (pageSize);保留 string 类型兼容性;limit 必须在 1-100 之间"},
|
||||
{Name: "page", Type: shortcut.FlagString, Desc: "页码 (pageIndex,从 0 开始);保留 string 类型兼容性;page 不能小于 0"},
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
_, _, err := roomFindPageValues(rt)
|
||||
return err
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "limit 必须在 1-100 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "page 不能小于 0"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws calendar +room-find --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
pageSize, pageIndex, err := roomFindPageValues(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
startStr := rt.Str("start")
|
||||
endStr := rt.Str("end")
|
||||
@@ -487,9 +492,14 @@ var RoomFind = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if endMs <= startMs {
|
||||
return fmt.Errorf("--end 必须晚于 --start")
|
||||
}
|
||||
params := map[string]any{
|
||||
"startTime": startMs,
|
||||
"endTime": endMs,
|
||||
"pageSize": strconv.Itoa(pageSize),
|
||||
"pageIndex": strconv.Itoa(pageIndex),
|
||||
}
|
||||
if rt.Bool("available") {
|
||||
params["needAvailable"] = true
|
||||
@@ -500,16 +510,54 @@ var RoomFind = shortcut.Shortcut{
|
||||
if rt.Changed("room-name") {
|
||||
params["roomName"] = strings.TrimSpace(rt.Str("room-name"))
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Str("limit")
|
||||
data, err := rt.CallMCPData("calendar", "query_available_meeting_room", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Changed("page") {
|
||||
params["pageIndex"] = rt.Str("page")
|
||||
items, container, err := requireCalendarCollection(data, "calendar/query_available_meeting_room", "rooms", "roomList", "meetingRoomList", "meetingRooms", "items", "list")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("query_available_meeting_room", params)
|
||||
rooms, err := projectCalendarRows(items, "calendar/query_available_meeting_room", map[string][]string{
|
||||
"roomId": {"roomId", "room_id", "id"},
|
||||
"roomName": {"roomName", "room_name", "name", "summary"},
|
||||
"capacity": {"capacity", "maxCapacity", "seatCount", "seats"},
|
||||
"groupId": {"groupId", "group_id"},
|
||||
"supportRecurring": {"supportRecurring"},
|
||||
}, "roomId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]any{"count": len(rooms), "rooms": rooms, "page": pageIndex, "pageSize": pageSize, "complete": false}
|
||||
for _, key := range []string{"hasMore", "totalCount", "total", "pageIndex", "pageSize"} {
|
||||
if value, ok := container[key]; ok && value != nil {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if hasMore, ok := out["hasMore"].(bool); ok {
|
||||
out["complete"] = !hasMore
|
||||
}
|
||||
return rt.Output(out)
|
||||
},
|
||||
}
|
||||
|
||||
func roomFindPageValues(rt *shortcut.RuntimeContext) (pageSize int, pageIndex int, err error) {
|
||||
pageSize = 20
|
||||
if rt.Changed("limit") {
|
||||
pageSize, err = strconv.Atoi(strings.TrimSpace(rt.Str("limit")))
|
||||
if err != nil || pageSize < 1 || pageSize > 100 {
|
||||
return 0, 0, fmt.Errorf("--limit 必须是 1-100 之间的整数")
|
||||
}
|
||||
}
|
||||
if rt.Changed("page") {
|
||||
pageIndex, err = strconv.Atoi(strings.TrimSpace(rt.Str("page")))
|
||||
if err != nil || pageIndex < 0 {
|
||||
return 0, 0, fmt.Errorf("--page 必须是大于或等于 0 的整数")
|
||||
}
|
||||
}
|
||||
return pageSize, pageIndex, nil
|
||||
}
|
||||
|
||||
// RoomAdd → add_meeting_room
|
||||
// RoomRemove → delete_meeting_room
|
||||
// RoomGroups → list_meeting_room_groups
|
||||
@@ -562,7 +610,10 @@ var RoomGroups = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := roomGroupsProject(data)
|
||||
groups, err := roomGroupsProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(groups), "groups": groups})
|
||||
},
|
||||
}
|
||||
@@ -572,29 +623,15 @@ var RoomGroups = shortcut.Shortcut{
|
||||
// the framework applies to every list command. The list container and each field
|
||||
// are probed defensively across candidate keys, since group payloads may nest
|
||||
// under result/data/list/items with aliases.
|
||||
func roomGroupsProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
func roomGroupsProject(data map[string]any) ([]map[string]any, error) {
|
||||
items, _, err := requireCalendarCollection(data, "calendar/list_meeting_room_groups", "groupList", "groups", "items", "list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := roomGroupsContainer(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, ok := roomGroupsFirst(m, "groupId", "group_id", "id"); ok {
|
||||
row["groupId"] = v
|
||||
}
|
||||
if v, ok := roomGroupsFirst(m, "groupName", "group_name", "name", "summary"); ok {
|
||||
row["groupName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return projectCalendarRows(items, "calendar/list_meeting_room_groups", map[string][]string{
|
||||
"groupId": {"groupId", "group_id", "id"},
|
||||
"groupName": {"groupName", "group_name", "name", "summary"},
|
||||
}, "groupId")
|
||||
}
|
||||
|
||||
// roomGroupsContainer locates the group slice across candidate wrapper keys,
|
||||
@@ -699,10 +736,62 @@ var BusySearch = shortcut.Shortcut{
|
||||
if len(rt.StrSlice("rooms")) > 0 {
|
||||
params["roomIds"] = rt.StrSlice("rooms")
|
||||
}
|
||||
return rt.CallMCP("query_busy_status", params)
|
||||
data, err := rt.CallMCPData("calendar", "query_busy_status", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
busy, err := busySearchProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(busy), "busy": busy, "free": len(busy) == 0, "complete": true})
|
||||
},
|
||||
}
|
||||
|
||||
func busySearchProject(data map[string]any) ([]map[string]any, error) {
|
||||
entries, _, err := requireCalendarCollection(data, "calendar/query_busy_status", "busy", "entries")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0)
|
||||
for entryIndex, rawEntry := range entries {
|
||||
entry := rawEntry.(map[string]any)
|
||||
rawItems, present := entry["scheduleItems"]
|
||||
if !present {
|
||||
return nil, calendarResponseError("calendar/query_busy_status", "missing_schedule_items", fmt.Sprintf("result[%d] 缺少 scheduleItems", entryIndex))
|
||||
}
|
||||
items, ok := rawItems.([]any)
|
||||
if !ok {
|
||||
return nil, calendarResponseError("calendar/query_busy_status", "malformed_schedule_items", fmt.Sprintf("result[%d].scheduleItems 不是数组", entryIndex))
|
||||
}
|
||||
if err := validateCalendarCollectionItems(items, "calendar/query_busy_status", "scheduleItems"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for itemIndex, rawItem := range items {
|
||||
item := rawItem.(map[string]any)
|
||||
start := calendarBusyDateTime(item["start"])
|
||||
end := calendarBusyDateTime(item["end"])
|
||||
if calendarEmptyValue(start) || calendarEmptyValue(end) {
|
||||
return nil, calendarResponseError("calendar/query_busy_status", "malformed_schedule_time", fmt.Sprintf("scheduleItems[%d] 缺少开始或结束时间", itemIndex))
|
||||
}
|
||||
out = append(out, map[string]any{"start": start, "end": end})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func calendarBusyDateTime(value any) any {
|
||||
if object, ok := value.(map[string]any); ok {
|
||||
if dateTime, present := object["dateTime"]; present {
|
||||
return dateTime
|
||||
}
|
||||
if date, present := object["date"]; present {
|
||||
return date
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ── attachment: 附件 ──────────────────────────────────────────
|
||||
|
||||
// AttachmentAdd → add_attachments
|
||||
@@ -752,35 +841,28 @@ var BookList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
books := bookListProject(data)
|
||||
books, err := bookListProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(books), "calendars": books})
|
||||
},
|
||||
}
|
||||
|
||||
// bookListProject reshapes list_calendars into a clean calendar-book list
|
||||
// (calendarId/summary/privilege/type) — clean output projection.
|
||||
func bookListProject(data map[string]any) []map[string]any {
|
||||
raw, ok := data["result"].([]any)
|
||||
if !ok {
|
||||
return []map[string]any{}
|
||||
func bookListProject(data map[string]any) ([]map[string]any, error) {
|
||||
items, _, err := requireCalendarCollection(data, "calendar/list_calendars", "calendars", "calendarList", "items", "list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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{}
|
||||
for _, k := range []string{"calendarId", "summary", "privilege", "type", "description"} {
|
||||
if v, ok := m[k]; ok {
|
||||
row[k] = v
|
||||
}
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return projectCalendarRows(items, "calendar/list_calendars", map[string][]string{
|
||||
"calendarId": {"calendarId", "calendar_id", "id"},
|
||||
"summary": {"summary", "name", "title"},
|
||||
"privilege": {"privilege", "role", "accessRole"},
|
||||
"type": {"type", "calendarType", "calendar_type"},
|
||||
"description": {"description", "desc"},
|
||||
}, "calendarId")
|
||||
}
|
||||
|
||||
// BookGet → get_calendar
|
||||
@@ -826,7 +908,10 @@ var BookSearch = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendars := bookSearchProject(data)
|
||||
calendars, err := bookSearchProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(calendars), "calendars": calendars})
|
||||
},
|
||||
}
|
||||
@@ -835,35 +920,17 @@ var BookSearch = shortcut.Shortcut{
|
||||
// stable calendar-book list (calendarId/summary/privilege/type) — output-projection
|
||||
// clean output projection. The list container and each field are probed defensively
|
||||
// across candidate keys, tolerating nesting under result/data/list/items.
|
||||
func bookSearchProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
func bookSearchProject(data map[string]any) ([]map[string]any, error) {
|
||||
items, _, err := requireCalendarCollection(data, "calendar/search_calendar", "calendars", "calendarList", "items", "list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := bookSearchContainer(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, ok := bookSearchFirst(m, "calendarId", "calendar_id", "id"); ok {
|
||||
row["calendarId"] = v
|
||||
}
|
||||
if v, ok := bookSearchFirst(m, "summary", "name", "title"); ok {
|
||||
row["summary"] = v
|
||||
}
|
||||
if v, ok := bookSearchFirst(m, "privilege", "role", "accessRole"); ok {
|
||||
row["privilege"] = v
|
||||
}
|
||||
if v, ok := bookSearchFirst(m, "type", "calendarType", "calendar_type"); ok {
|
||||
row["type"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return projectCalendarRows(items, "calendar/search_calendar", map[string][]string{
|
||||
"calendarId": {"calendarId", "calendar_id", "id"},
|
||||
"summary": {"summary", "name", "title"},
|
||||
"privilege": {"privilege", "role", "accessRole"},
|
||||
"type": {"type", "calendarType", "calendar_type"},
|
||||
}, "calendarId")
|
||||
}
|
||||
|
||||
// bookSearchContainer locates the calendar slice across candidate wrapper keys,
|
||||
@@ -901,8 +968,37 @@ func bookSearchFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
|
||||
// BookUpdate → update_calendar
|
||||
func init() {
|
||||
EventList.Contract.Parameters = []contract.ParamDecl{
|
||||
{Name: "start", Property: "start"}, {Name: "end", Property: "end"},
|
||||
{Name: "calendar-id", Property: "calendarId"}, {Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"},
|
||||
}
|
||||
EventList.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if !rt.Changed("limit") {
|
||||
return nil
|
||||
}
|
||||
return rt.RangeInt("limit", 1, 100)
|
||||
}
|
||||
EventList.Constraints = []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "limit 必须在 1-100 之间"},
|
||||
}
|
||||
finalizeCalendarShortcut(&EventList, calendarCollectionResult("events", "严格校验并保留分页证据的日程当前页"), calendarCursorPagination())
|
||||
finalizeCalendarShortcut(&AttendeeList, calendarObjectResult("严格校验的日程参会人列表"), nil)
|
||||
finalizeCalendarShortcut(&RoomSearch, calendarObjectResult("严格校验的按名会议室列表"), nil)
|
||||
finalizeCalendarShortcut(&RoomFind, RoomFind.Contract.Result, nil)
|
||||
finalizeCalendarShortcut(&RoomGroups, calendarObjectResult("严格校验的会议室分组当前页"), nil)
|
||||
finalizeCalendarShortcut(&BusySearch, calendarObjectResult("用户或会议室忙闲结果"), nil)
|
||||
finalizeCalendarShortcut(&BookList, calendarObjectResult("严格校验的日历本列表"), nil)
|
||||
finalizeCalendarShortcut(&BookSearch, calendarObjectResult("严格校验的日历本搜索结果"), nil)
|
||||
EventSearch.Contract.Pagination = calendarCursorPagination()
|
||||
shortcut.Register(
|
||||
EventList,
|
||||
EventGet,
|
||||
EventCreate,
|
||||
EventUpdate,
|
||||
RSVP,
|
||||
EventSearch,
|
||||
Suggestion,
|
||||
AttendeeList,
|
||||
RoomSearch,
|
||||
RoomFind,
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const (
|
||||
calendarCoverageStart = "2026-08-17T09:00:00+08:00"
|
||||
calendarCoverageEnd = "2026-08-17T10:00:00+08:00"
|
||||
calendarCoverageEvent = `{"success":true,"result":{"id":"event-1","summary":"updated","description":"description","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"},"timeZone":"Asia/Shanghai","location":"room","freeBusy":"busy"}}`
|
||||
)
|
||||
|
||||
func calendarRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarCommonBranches(t *testing.T) {
|
||||
if got := calendarExample("+unknown"); !strings.Contains(got, "+unknown") {
|
||||
t.Fatalf("default example=%q", got)
|
||||
}
|
||||
if calendarReadShortcut("+x", "x", "x", "items", nil, nil, nil).Contract.Result == nil {
|
||||
t.Fatal("collection read shortcut did not build a result")
|
||||
}
|
||||
collectionResult := calendarCollectionResult("items", "items")
|
||||
var collectionSchema struct {
|
||||
Properties map[string]json.RawMessage `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(collectionResult.DataSchema, &collectionSchema); err != nil {
|
||||
t.Fatalf("decode collection result schema: %v", err)
|
||||
}
|
||||
for _, field := range []string{"hasMore", "nextCursor"} {
|
||||
if _, exists := collectionSchema.Properties[field]; exists {
|
||||
t.Fatalf("pagination field %q leaked into business Result schema", field)
|
||||
}
|
||||
}
|
||||
if _, exists := collectionSchema.Properties["complete"]; !exists {
|
||||
t.Fatal("collection Result schema lost business completeness evidence")
|
||||
}
|
||||
if calendarWriteShortcut("+x", "x", "x", "dws calendar +x", nil, nil, nil).Safety.Confirmation != "user_required" {
|
||||
t.Fatal("write shortcut did not build safety")
|
||||
}
|
||||
|
||||
badResponses := []map[string]any{
|
||||
{"success": "yes"},
|
||||
{"success": false},
|
||||
{"result": map[string]any{}},
|
||||
}
|
||||
for _, response := range badResponses {
|
||||
if _, err := requireCalendarObject(response, "calendar/test"); err == nil {
|
||||
t.Fatalf("bad object accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
if got := calendarContainers(nil); got != nil {
|
||||
t.Fatalf("nil containers=%#v", got)
|
||||
}
|
||||
if !calendarOnlyEnvelopeFields(map[string]any{"success": true, "result": map[string]any{}}) || calendarOnlyEnvelopeFields(map[string]any{"id": "x"}) {
|
||||
t.Fatal("envelope field classification drift")
|
||||
}
|
||||
|
||||
if _, _, err := requireCalendarCollection(map[string]any{"success": false}, "calendar/test", "items"); err == nil {
|
||||
t.Fatal("failed response accepted as collection")
|
||||
}
|
||||
if _, _, err := requireCalendarCollection(map[string]any{"success": true, "result": []any{"bad"}}, "calendar/test", "items"); err == nil {
|
||||
t.Fatal("bad wrapper array accepted")
|
||||
}
|
||||
items, _, err := requireCalendarCollection(map[string]any{"success": true, "result": []any{map[string]any{"id": "x"}}}, "calendar/test", "items")
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("wrapper array rejected: %#v %v", items, err)
|
||||
}
|
||||
if _, err := projectCalendarRows([]any{map[string]any{"ignored": "x"}}, "calendar/test", map[string][]string{"id": {"id"}}, "id"); err == nil {
|
||||
t.Fatal("unidentifiable projected row accepted")
|
||||
}
|
||||
if !calendarEmptyValue(nil) || !calendarEmptyValue(" ") || calendarEmptyValue(1) {
|
||||
t.Fatal("calendarEmptyValue classification drift")
|
||||
}
|
||||
|
||||
if page, err := calendarPagination(nil, "calendar/test"); err != nil || page.Known {
|
||||
t.Fatalf("nil pagination=%+v %v", page, err)
|
||||
}
|
||||
for _, container := range []map[string]any{
|
||||
{"hasMore": "yes"},
|
||||
{"hasMore": true, "nextCursor": 1},
|
||||
{"hasMore": false, "nextCursor": "cursor"},
|
||||
} {
|
||||
if _, err := calendarPagination(container, "calendar/test"); err == nil {
|
||||
t.Fatalf("bad pagination accepted: %#v", container)
|
||||
}
|
||||
}
|
||||
if _, err := calendarPagination(map[string]any{"nextToken": "cursor"}, "calendar/test"); err == nil {
|
||||
t.Fatal("cursor without hasMore accepted")
|
||||
}
|
||||
page, err := calendarPagination(map[string]any{"hasMore": true, "nextToken": "cursor"}, "calendar/test")
|
||||
if err != nil || !page.Known || !page.HasMore || page.NextCursor != "cursor" {
|
||||
t.Fatalf("cursor-only pagination=%+v %v", page, err)
|
||||
}
|
||||
out := map[string]any{}
|
||||
addCalendarPagination(out, page)
|
||||
if out["nextCursor"] != "cursor" || out["hasMore"] != true {
|
||||
t.Fatalf("pagination output=%#v", out)
|
||||
}
|
||||
|
||||
if _, _, err := validateCalendarRange("start", "bad", "end", calendarCoverageEnd); err == nil {
|
||||
t.Fatal("bad start accepted")
|
||||
}
|
||||
if _, _, err := validateCalendarRange("start", calendarCoverageStart, "end", "bad"); err == nil {
|
||||
t.Fatal("bad end accepted")
|
||||
}
|
||||
if _, _, err := validateCalendarRange("start", calendarCoverageEnd, "end", calendarCoverageStart); err == nil {
|
||||
t.Fatal("reversed range accepted")
|
||||
}
|
||||
if got := calendarStringList([]string{" a ", "", "a", "b"}); len(got) != 2 {
|
||||
t.Fatalf("deduplicated list=%#v", got)
|
||||
}
|
||||
|
||||
want := time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC)
|
||||
millis := want.UnixMilli()
|
||||
for _, actual := range []any{
|
||||
map[string]any{"value": calendarCoverageStart},
|
||||
calendarCoverageStart,
|
||||
strconv.FormatInt(millis, 10),
|
||||
float64(millis), int64(millis), int(millis),
|
||||
} {
|
||||
if !calendarTimeEquivalent(actual, calendarCoverageStart) {
|
||||
t.Fatalf("equivalent time rejected: %#v", actual)
|
||||
}
|
||||
}
|
||||
for _, actual := range []any{math.NaN(), math.Inf(1), 1.5, struct{}{}} {
|
||||
if calendarTimeEquivalent(actual, calendarCoverageStart) {
|
||||
t.Fatalf("bad time accepted: %#v", actual)
|
||||
}
|
||||
}
|
||||
if calendarTimeEquivalent(calendarCoverageStart, "bad") {
|
||||
t.Fatal("bad expected time accepted")
|
||||
}
|
||||
|
||||
base := map[string]any{"id": "event-1", "summary": "updated", "start": map[string]any{"dateTime": calendarCoverageStart}}
|
||||
if err := verifyCalendarEvent(base, "wrong", nil); err == nil {
|
||||
t.Fatal("wrong event id accepted")
|
||||
}
|
||||
if err := verifyCalendarEvent(base, "event-1", map[string]any{"unknown": "ignored"}); err != nil {
|
||||
t.Fatalf("unknown verification field rejected: %v", err)
|
||||
}
|
||||
if err := verifyCalendarEvent(base, "event-1", map[string]any{"description": "x"}); err == nil {
|
||||
t.Fatal("missing readback field accepted")
|
||||
}
|
||||
if err := verifyCalendarEvent(base, "event-1", map[string]any{"summary": "wrong"}); err == nil {
|
||||
t.Fatal("mismatched readback field accepted")
|
||||
}
|
||||
if err := verifyCalendarEvent(base, "event-1", map[string]any{"startDateTime": calendarCoverageEnd}); err == nil {
|
||||
t.Fatal("mismatched readback time accepted")
|
||||
}
|
||||
item := shortcut.Shortcut{Description: "description", Intent: "intent"}
|
||||
finalizeCalendarShortcut(&item, calendarObjectResult("result"), calendarCursorPagination())
|
||||
if item.Contract.Interface == nil || item.Contract.Result == nil || item.Contract.Pagination == nil {
|
||||
t.Fatalf("finalized shortcut=%#v", item)
|
||||
}
|
||||
pageShortcut := EventGet
|
||||
pageShortcut.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return outputCalendarPage(rt, map[string]any{"count": 0}, calendarPageEvidence{})
|
||||
}
|
||||
if err := runCalendarCoverage(t, pageShortcut, &calendarCoverageCaller{responses: map[string][]string{}}, "--event", "event-1"); err == nil {
|
||||
t.Fatal("unknown pagination was emitted")
|
||||
}
|
||||
pageShortcut.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return outputCalendarPage(rt, map[string]any{"count": 0}, calendarPageEvidence{Known: true, NextCursor: "cursor"})
|
||||
}
|
||||
if err := runCalendarCoverage(t, pageShortcut, &calendarCoverageCaller{responses: map[string][]string{}}, "--event", "event-1"); err == nil {
|
||||
t.Fatal("inconsistent pagination was emitted")
|
||||
}
|
||||
legacyPage := EventGet
|
||||
legacyPage.OutputRollout = output.RolloutLegacyOnly
|
||||
legacyPage.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return outputCalendarPage(rt, map[string]any{"count": 0}, calendarPageEvidence{Known: true})
|
||||
}
|
||||
if err := runCalendarCoverage(t, legacyPage, &calendarCoverageCaller{responses: map[string][]string{}}, "--event", "event-1"); err != nil {
|
||||
t.Fatalf("legacy pagination output: %v", err)
|
||||
}
|
||||
|
||||
unifiedPage := EventGet
|
||||
unifiedPage.OutputRollout = output.RolloutUnifiedActive
|
||||
cmd := corecmd.New(shortcut.FromShortcut(unifiedPage))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, unifiedPage)
|
||||
if err := outputCalendarPage(rt, map[string]any{"count": 1, "items": []any{map[string]any{"id": "item-1"}}}, calendarPageEvidence{Known: true, HasMore: true, NextCursor: "cursor-2"}); err != nil {
|
||||
t.Fatalf("store unified pagination result: %v", err)
|
||||
}
|
||||
if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 {
|
||||
t.Fatalf("emit unified pagination result: code=%d emitted=%v err=%v", code, emitted, err)
|
||||
}
|
||||
var envelope struct {
|
||||
Data map[string]any `json:"data"`
|
||||
Meta struct {
|
||||
Pagination *struct {
|
||||
EndpointExhausted bool `json:"endpoint_exhausted"`
|
||||
NextToken string `json:"next_token"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode unified pagination output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
for _, field := range []string{"hasMore", "nextCursor"} {
|
||||
if _, exists := envelope.Data[field]; exists {
|
||||
t.Fatalf("pagination field %q leaked into business data: %s", field, stdout.String())
|
||||
}
|
||||
}
|
||||
if envelope.Data["complete"] != false {
|
||||
t.Fatalf("business completeness=%#v, want false: %s", envelope.Data["complete"], stdout.String())
|
||||
}
|
||||
if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted || envelope.Meta.Pagination.NextToken != "cursor-2" {
|
||||
t.Fatalf("pagination meta=%+v: %s", envelope.Meta.Pagination, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarEventGetSearchAndSuggestion(t *testing.T) {
|
||||
get := &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {`{"success":true,"result":{"id":"event-1"}}`}}}
|
||||
if err := runCalendarCoverage(t, EventGet, get, "--event", "event-1", "--calendar-id", "primary"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
getFailure := &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {"__ERROR__"}}}
|
||||
if err := runCalendarCoverage(t, EventGet, getFailure, "--event", "event-1"); err == nil {
|
||||
t.Fatal("get transport failure accepted")
|
||||
}
|
||||
|
||||
search := &calendarCoverageCaller{responses: map[string][]string{"list_calendar_events": {`{"success":true,"result":{"events":[{"id":"event-1","summary":"Needle","description":"body","location":"room"},{"id":"event-2","summary":"other"}],"hasMore":true,"nextCursor":"next"}}`}}}
|
||||
if err := runCalendarCoverage(t, EventSearch, search, "--query", "needle", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--calendar-id", "primary", "--cursor", "cursor", "--limit", "2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, response := range map[string]string{
|
||||
"call": "__ERROR__",
|
||||
"collection": `{"success":true,"result":{"hasMore":false}}`,
|
||||
"item": `{"success":true,"result":{"events":[{"summary":"x"}],"hasMore":false}}`,
|
||||
"pagination-bad": `{"success":true,"result":{"events":[],"hasMore":"bad"}}`,
|
||||
"pagination": `{"success":true,"result":{"events":[]}}`,
|
||||
} {
|
||||
t.Run("search-"+name, func(t *testing.T) {
|
||||
caller := &calendarCoverageCaller{responses: map[string][]string{"list_calendar_events": {response}}}
|
||||
if err := runCalendarCoverage(t, EventSearch, caller, "--query", "x"); err == nil {
|
||||
t.Fatal("bad search response accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
suggestion := &calendarCoverageCaller{responses: map[string][]string{"list_suggested_event_times": {`{"success":true,"result":{"recommendEventTimes":[{"startTime":"2026-08-17T09:00:00+08:00","endTime":"2026-08-17T10:00:00+08:00"}]}}`}}}
|
||||
if err := runCalendarCoverage(t, Suggestion, suggestion, "--users", " user-1,user-1 ", "--duration", "30", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--timezone", "Asia/Shanghai"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, args := range map[string][]string{
|
||||
"duration": {"--users", "u", "--duration", "0"},
|
||||
"range-pair": {"--users", "u", "--start", calendarCoverageStart},
|
||||
"range-order": {"--users", "u", "--start", calendarCoverageEnd, "--end", calendarCoverageStart},
|
||||
} {
|
||||
t.Run("suggestion-"+name, func(t *testing.T) {
|
||||
if err := runCalendarCoverage(t, Suggestion, &calendarCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
|
||||
t.Fatal("bad suggestion arguments accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
emptyUsers := Suggestion
|
||||
for index := range emptyUsers.Flags {
|
||||
if emptyUsers.Flags[index].Name == "users" {
|
||||
emptyUsers.Flags[index].Required = false
|
||||
}
|
||||
}
|
||||
if err := runCalendarCoverage(t, emptyUsers, &calendarCoverageCaller{responses: map[string][]string{}}); err == nil {
|
||||
t.Fatal("empty suggestion users accepted")
|
||||
}
|
||||
for name, response := range map[string]string{
|
||||
"call": "__ERROR__",
|
||||
"collection": `{"success":true,"result":{}}`,
|
||||
"projection": `{"success":true,"result":{"recommendEventTimes":[{"endTime":"x"}]}}`,
|
||||
"missing-end": `{"success":true,"result":{"recommendEventTimes":[{"startTime":"x"}]}}`,
|
||||
} {
|
||||
t.Run("suggestion-response-"+name, func(t *testing.T) {
|
||||
caller := &calendarCoverageCaller{responses: map[string][]string{"list_suggested_event_times": {response}}}
|
||||
if err := runCalendarCoverage(t, Suggestion, caller, "--users", "u"); err == nil {
|
||||
t.Fatal("bad suggestion response accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarCreateAndRSVPBranches(t *testing.T) {
|
||||
attendees := make([]string, 501)
|
||||
for index := range attendees {
|
||||
attendees[index] = "u" + strconv.Itoa(index)
|
||||
}
|
||||
for name, args := range map[string][]string{
|
||||
"title": {"--title", strings.Repeat("x", 2049), "--start", calendarCoverageStart, "--end", calendarCoverageEnd},
|
||||
"desc": {"--title", "x", "--desc", strings.Repeat("x", 5001), "--start", calendarCoverageStart, "--end", calendarCoverageEnd},
|
||||
"range": {"--title", "x", "--start", calendarCoverageEnd, "--end", calendarCoverageStart},
|
||||
"attendees": {"--title", "x", "--attendees", strings.Join(attendees, ","), "--start", calendarCoverageStart, "--end", calendarCoverageEnd},
|
||||
} {
|
||||
t.Run("create-args-"+name, func(t *testing.T) {
|
||||
if err := runCalendarCoverage(t, EventCreate, &calendarCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
|
||||
t.Fatal("bad create args accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
directCases := []map[string]string{
|
||||
{"title": strings.Repeat("x", 2049), "start": calendarCoverageStart, "end": calendarCoverageEnd},
|
||||
{"title": "x", "desc": strings.Repeat("x", 5001), "start": calendarCoverageStart, "end": calendarCoverageEnd},
|
||||
{"title": "x", "start": calendarCoverageEnd, "end": calendarCoverageStart},
|
||||
{"title": "x", "attendees": strings.Join(attendees, ","), "start": calendarCoverageStart, "end": calendarCoverageEnd},
|
||||
}
|
||||
for index, values := range directCases {
|
||||
rt := calendarRuntimeForTest(t, EventCreate, values)
|
||||
if _, _, err := calendarCreateParams(rt); err == nil {
|
||||
t.Fatalf("direct invalid create case %d accepted", index)
|
||||
}
|
||||
}
|
||||
if err := EventCreate.Execute(calendarRuntimeForTest(t, EventCreate, directCases[0])); err == nil {
|
||||
t.Fatal("create execute accepted invalid params")
|
||||
}
|
||||
if err := runCalendarCoverage(t, EventCreate, &calendarCoverageCaller{responses: map[string][]string{}}, "--title", "x", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--dry-run", "--yes"); err != nil {
|
||||
t.Fatalf("create dry-run: %v", err)
|
||||
}
|
||||
for name, responses := range map[string]map[string][]string{
|
||||
"write-call": {"create_calendar_event": {"__ERROR__"}},
|
||||
"write-shape": {"create_calendar_event": {`{"result":{"id":"event-1"}}`}},
|
||||
"missing-id": {"create_calendar_event": {`{"success":true,"result":{}}`}},
|
||||
"read-call": {"create_calendar_event": {`{"success":true,"result":{"id":"event-1"}}`}, "get_calendar_detail": {"__ERROR__"}},
|
||||
"read-shape": {"create_calendar_event": {`{"success":true,"result":{"id":"event-1"}}`}, "get_calendar_detail": {`{"success":true,"result":{}}`}},
|
||||
"read-mismatch": {"create_calendar_event": {`{"success":true,"result":{"id":"event-1"}}`}, "get_calendar_detail": {`{"success":true,"result":{"id":"event-1","summary":"wrong","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}}}`}},
|
||||
} {
|
||||
t.Run("create-"+name, func(t *testing.T) {
|
||||
caller := &calendarCoverageCaller{responses: responses}
|
||||
if err := runCalendarCoverage(t, EventCreate, caller, "--title", "x", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--calendar-id", "primary", "--yes"); err == nil {
|
||||
t.Fatal("bad create path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
preflight := `{"success":true,"result":{"id":"event-1"}}`
|
||||
if err := runCalendarCoverage(t, RSVP, &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {preflight}}}, "--event", "event-1", "--status", "accept", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatalf("rsvp dry-run: %v", err)
|
||||
}
|
||||
for name, responses := range map[string]map[string][]string{
|
||||
"preflight-call": {"get_calendar_detail": {"__ERROR__"}},
|
||||
"preflight-shape": {"get_calendar_detail": {`{"success":true,"result":{}}`}},
|
||||
"write-call": {"get_calendar_detail": {preflight}, "respond": {"__ERROR__"}},
|
||||
"write-shape": {"get_calendar_detail": {preflight}, "respond": {`{"result":{}}`}},
|
||||
"read-call": {"get_calendar_detail": {preflight, "__ERROR__"}, "respond": {`{"success":true}`}},
|
||||
"read-shape": {"get_calendar_detail": {preflight, `{"success":true,"result":{}}`}, "respond": {`{"success":true}`}},
|
||||
"read-mismatch": {"get_calendar_detail": {preflight, `{"success":true,"result":{"id":"event-1","responseStatus":"declined"}}`}, "respond": {`{"success":true}`}},
|
||||
"write-mismatch": {"get_calendar_detail": {preflight, preflight}, "respond": {`{"success":true,"result":{"status":"declined"}}`}},
|
||||
} {
|
||||
t.Run("rsvp-"+name, func(t *testing.T) {
|
||||
if err := runCalendarCoverage(t, RSVP, &calendarCoverageCaller{responses: responses}, "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"); err == nil {
|
||||
t.Fatal("bad RSVP path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, write := range map[string]string{"readback": `{"success":true}`, "terminal": `{"success":true,"result":{"status":"accepted"}}`} {
|
||||
t.Run("rsvp-success-"+name, func(t *testing.T) {
|
||||
read := preflight
|
||||
if name == "readback" {
|
||||
read = `{"success":true,"result":{"id":"event-1","responseStatus":"accepted"}}`
|
||||
}
|
||||
caller := &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {preflight, read}, "respond": {write}}}
|
||||
if err := runCalendarCoverage(t, RSVP, caller, "--event", "event-1", "--status", "accept", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarUpdateStages(t *testing.T) {
|
||||
for name, args := range map[string][]string{
|
||||
"none": {"--event", "event-1", "--yes"},
|
||||
"pair": {"--event", "event-1", "--start", calendarCoverageStart, "--yes"},
|
||||
"range": {"--event", "event-1", "--start", calendarCoverageEnd, "--end", calendarCoverageStart, "--yes"},
|
||||
"overlap": {"--event", "event-1", "--add-attendees", "u", "--remove-attendees", "u", "--yes"},
|
||||
} {
|
||||
t.Run("args-"+name, func(t *testing.T) {
|
||||
if err := runCalendarCoverage(t, EventUpdate, &calendarCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
|
||||
t.Fatal("bad update args accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
preflight := `{"success":true,"result":{"id":"event-1"}}`
|
||||
if err := runCalendarCoverage(t, EventUpdate, &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {preflight}}}, "--event", "event-1", "--title", "updated", "--add-attendees", "u-add", "--remove-attendees", "u-remove", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatalf("update dry-run: %v", err)
|
||||
}
|
||||
success := map[string][]string{
|
||||
"get_calendar_detail": {preflight, calendarCoverageEvent},
|
||||
"update_calendar_event": {`{"success":true}`},
|
||||
"remove_calendar_participant": {`{"success":true}`},
|
||||
"add_calendar_participant": {`{"success":true}`},
|
||||
"get_calendar_participants": {`{"success":true,"result":{"attendees":[{"displayName":"added","userId":"u-add"}]}}`},
|
||||
}
|
||||
if err := runCalendarCoverage(t, EventUpdate, &calendarCoverageCaller{responses: success}, "--event", "event-1", "--title", "updated", "--desc", "description", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--timezone", "Asia/Shanghai", "--location", "room", "--free-busy", "busy", "--add-attendees", "u-add", "--remove-attendees", "u-remove", "--calendar-id", "primary", "--yes"); err != nil {
|
||||
t.Fatalf("full update: %v", err)
|
||||
}
|
||||
|
||||
type updateFailure struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
args []string
|
||||
}
|
||||
baseArgs := []string{"--event", "event-1", "--title", "updated", "--yes"}
|
||||
cases := []updateFailure{
|
||||
{"preflight-call", map[string][]string{"get_calendar_detail": {"__ERROR__"}}, baseArgs},
|
||||
{"preflight-shape", map[string][]string{"get_calendar_detail": {`{"success":true,"result":{}}`}}, baseArgs},
|
||||
{"update-call", map[string][]string{"get_calendar_detail": {preflight}, "update_calendar_event": {"__ERROR__"}}, baseArgs},
|
||||
{"update-shape", map[string][]string{"get_calendar_detail": {preflight}, "update_calendar_event": {`{"result":{}}`}}, baseArgs},
|
||||
{"remove-call", map[string][]string{"get_calendar_detail": {preflight}, "remove_calendar_participant": {"__ERROR__"}}, []string{"--event", "event-1", "--remove-attendees", "u", "--yes"}},
|
||||
{"remove-shape", map[string][]string{"get_calendar_detail": {preflight}, "remove_calendar_participant": {`{"result":{}}`}}, []string{"--event", "event-1", "--remove-attendees", "u", "--yes"}},
|
||||
{"add-call", map[string][]string{"get_calendar_detail": {preflight}, "add_calendar_participant": {"__ERROR__"}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
|
||||
{"add-shape", map[string][]string{"get_calendar_detail": {preflight}, "add_calendar_participant": {`{"result":{}}`}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
|
||||
{"verify-call", map[string][]string{"get_calendar_detail": {preflight, "__ERROR__"}, "update_calendar_event": {`{"success":true}`}}, baseArgs},
|
||||
{"verify-shape", map[string][]string{"get_calendar_detail": {preflight, `{"success":true,"result":{}}`}, "update_calendar_event": {`{"success":true}`}}, baseArgs},
|
||||
{"verify-field", map[string][]string{"get_calendar_detail": {preflight, `{"success":true,"result":{"id":"event-1","summary":"wrong"}}`}, "update_calendar_event": {`{"success":true}`}}, baseArgs},
|
||||
{"attendee-call", map[string][]string{"get_calendar_detail": {preflight, preflight}, "add_calendar_participant": {`{"success":true}`}, "get_calendar_participants": {"__ERROR__"}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
|
||||
{"attendee-shape", map[string][]string{"get_calendar_detail": {preflight, preflight}, "add_calendar_participant": {`{"success":true}`}, "get_calendar_participants": {`{"success":true,"result":{}}`}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
|
||||
{"attendee-missing", map[string][]string{"get_calendar_detail": {preflight, preflight}, "add_calendar_participant": {`{"success":true}`}, "get_calendar_participants": {`{"success":true,"result":{"attendees":[]}}`}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
|
||||
{"attendee-present", map[string][]string{"get_calendar_detail": {preflight, preflight}, "remove_calendar_participant": {`{"success":true}`}, "get_calendar_participants": {`{"success":true,"result":{"attendees":[{"displayName":"still","userId":"u"}]}}`}}, []string{"--event", "event-1", "--remove-attendees", "u", "--yes"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := runCalendarCoverage(t, EventUpdate, &calendarCoverageCaller{responses: tc.responses}, tc.args...); err == nil {
|
||||
t.Fatal("bad update path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := calendarStageError("stage", []string{"done"}, nil); err == nil || !strings.Contains(err.Error(), "done") {
|
||||
t.Fatalf("stage error missing ledger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarLegacyStrictBranches(t *testing.T) {
|
||||
for name, args := range map[string][]string{
|
||||
"agenda-order": {"--start", calendarCoverageEnd, "--end", calendarCoverageStart},
|
||||
"room-limit": {"--limit", "bad"},
|
||||
"room-page": {"--page", "-1"},
|
||||
"room-start": {"--start", "bad"},
|
||||
"room-end": {"--start", calendarCoverageStart, "--end", "bad"},
|
||||
"room-order": {"--start", calendarCoverageEnd, "--end", calendarCoverageStart},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
declaration := EventList
|
||||
if strings.HasPrefix(name, "room-") {
|
||||
declaration = RoomFind
|
||||
}
|
||||
if err := runCalendarCoverage(t, declaration, &calendarCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
|
||||
t.Fatal("bad input accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
roomExecuteValidation := RoomFind
|
||||
roomExecuteValidation.Validate = nil
|
||||
if err := runCalendarCoverage(t, roomExecuteValidation, &calendarCoverageCaller{responses: map[string][]string{}}, "--limit", "bad"); err == nil {
|
||||
t.Fatal("room execute validation accepted bad limit")
|
||||
}
|
||||
if err := runCalendarCoverage(t, EventSearch, &calendarCoverageCaller{responses: map[string][]string{}}, "--query", "x", "--limit", "0"); err == nil {
|
||||
t.Fatal("search accepted bad limit")
|
||||
}
|
||||
if err := runCalendarCoverage(t, EventSearch, &calendarCoverageCaller{responses: map[string][]string{}}, "--query", "x", "--start", calendarCoverageEnd, "--end", calendarCoverageStart); err == nil {
|
||||
t.Fatal("search accepted reversed range")
|
||||
}
|
||||
for name, declarationAndTool := range map[string]struct {
|
||||
declaration shortcut.Shortcut
|
||||
tool string
|
||||
args []string
|
||||
}{
|
||||
"agenda": {EventList, "list_calendar_events", nil},
|
||||
"room": {RoomFind, "query_available_meeting_room", []string{"--start", calendarCoverageStart, "--end", calendarCoverageEnd}},
|
||||
"busy": {BusySearch, "query_busy_status", []string{"--users", "u", "--start", calendarCoverageStart, "--end", calendarCoverageEnd}},
|
||||
} {
|
||||
t.Run(name+"-call", func(t *testing.T) {
|
||||
caller := &calendarCoverageCaller{responses: map[string][]string{declarationAndTool.tool: {"__ERROR__"}}}
|
||||
if err := runCalendarCoverage(t, declarationAndTool.declaration, caller, declarationAndTool.args...); err == nil {
|
||||
t.Fatal("transport failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
room := &calendarCoverageCaller{responses: map[string][]string{"query_available_meeting_room": {`{"success":true,"result":{"rooms":[{"id":"room-1","name":"room"}],"hasMore":true,"totalCount":1,"pageIndex":0,"pageSize":20}}`}}}
|
||||
if err := runCalendarCoverage(t, RoomFind, room, "--group-id", "g", "--room-name", " room ", "--available"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, response := range []string{`{"success":true,"result":{}}`, `{"success":true,"result":{"rooms":[{"name":"missing id"}]}}`} {
|
||||
caller := &calendarCoverageCaller{responses: map[string][]string{"query_available_meeting_room": {response}}}
|
||||
if err := runCalendarCoverage(t, RoomFind, caller); err == nil {
|
||||
t.Fatal("malformed room response accepted")
|
||||
}
|
||||
}
|
||||
agendaProjection := &calendarCoverageCaller{responses: map[string][]string{"list_calendar_events": {`{"success":true,"result":{"events":[{"summary":"missing id"}],"hasMore":false}}`}}}
|
||||
if err := runCalendarCoverage(t, EventList, agendaProjection); err == nil {
|
||||
t.Fatal("agenda item without id accepted")
|
||||
}
|
||||
|
||||
for name, response := range map[string]string{
|
||||
"missing": `{"success":true,"result":{"busy":[{}]}}`,
|
||||
"bad-list": `{"success":true,"result":{"busy":[{"scheduleItems":{}}]}}`,
|
||||
"bad-item": `{"success":true,"result":{"busy":[{"scheduleItems":["bad"]}]}}`,
|
||||
"bad-time": `{"success":true,"result":{"busy":[{"scheduleItems":[{"start":{"date":"2026-08-17"}}]}]}}`,
|
||||
} {
|
||||
t.Run("busy-"+name, func(t *testing.T) {
|
||||
caller := &calendarCoverageCaller{responses: map[string][]string{"query_busy_status": {response}}}
|
||||
if err := runCalendarCoverage(t, BusySearch, caller, "--users", "u", "--rooms", "r", "--start", calendarCoverageStart, "--end", calendarCoverageEnd); err == nil {
|
||||
t.Fatal("bad busy response accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
validBusy := &calendarCoverageCaller{responses: map[string][]string{"query_busy_status": {`{"success":true,"result":{"busy":[{"scheduleItems":[{"start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"date":"2026-08-17"}}]}]}}`}}}
|
||||
if err := runCalendarCoverage(t, BusySearch, validBusy, "--users", "u", "--rooms", "r", "--start", calendarCoverageStart, "--end", calendarCoverageEnd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calendarBusyDateTime("raw") != "raw" {
|
||||
t.Fatal("raw busy time was not preserved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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 calendarCoverageCaller struct {
|
||||
responses map[string][]string
|
||||
history []string
|
||||
arguments []map[string]any
|
||||
}
|
||||
|
||||
func (caller *calendarCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
|
||||
caller.history = append(caller.history, tool)
|
||||
caller.arguments = append(caller.arguments, arguments)
|
||||
queue := caller.responses[tool]
|
||||
if len(queue) == 0 {
|
||||
return nil, errors.New("missing fake response for " + tool)
|
||||
}
|
||||
caller.responses[tool] = queue[1:]
|
||||
if queue[0] == "__ERROR__" {
|
||||
return nil, errors.New("injected calendar failure")
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
|
||||
}
|
||||
|
||||
func (*calendarCoverageCaller) Format() string { return "json" }
|
||||
func (*calendarCoverageCaller) DryRun() bool { return false }
|
||||
func (*calendarCoverageCaller) Fields() string { return "" }
|
||||
func (*calendarCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runCalendarCoverage(t *testing.T, declaration shortcut.Shortcut, caller *calendarCoverageCaller, args ...string) 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", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
service := &cobra.Command{Use: "calendar"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(append([]string{"calendar", declaration.Command}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPagination(t *testing.T) {
|
||||
var explicitEmpty map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[],"hasMore":false}}`), &explicitEmpty); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, page, err := eventListProject(explicitEmpty)
|
||||
if err != nil || len(events) != 0 || !page.Known || page.HasMore {
|
||||
t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err)
|
||||
}
|
||||
|
||||
for name, payload := range map[string]string{
|
||||
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
|
||||
"bad collection": `{"success":true,"result":{"events":{}}}`,
|
||||
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
|
||||
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
|
||||
"missing pagination": `{"success":true,"result":{"events":[]}}`,
|
||||
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := eventListProject(data); err == nil {
|
||||
t.Fatalf("payload unexpectedly accepted: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var nextPage map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"id":"event-1","summary":"review"}],"hasMore":true,"nextCursor":"cursor-2"}}`), &nextPage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, page, err = eventListProject(nextPage)
|
||||
if err != nil || len(events) != 1 || events[0]["eventId"] != "event-1" || !page.HasMore || page.NextCursor != "cursor-2" {
|
||||
t.Fatalf("next page projection: events=%#v page=%+v err=%v", events, page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarObjectsAndWritesFailClosed(t *testing.T) {
|
||||
for name, payload := range map[string]map[string]any{
|
||||
"empty": {},
|
||||
"success false": {"success": false, "errorMsg": "denied"},
|
||||
"nonterminal receipt": {"result": map[string]any{"id": "event-1"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := requireCalendarWriteResponse(payload, "calendar/write"); err == nil {
|
||||
t.Fatalf("payload unexpectedly accepted: %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := requireCalendarWriteResponse(map[string]any{"success": true, "result": map[string]any{"id": "event-1"}}, "calendar/write"); err != nil {
|
||||
t.Fatalf("terminal write rejected: %v", err)
|
||||
}
|
||||
event, err := requireCalendarEvent(map[string]any{"success": true, "result": map[string]any{"id": "event-1", "summary": "review"}}, "calendar/get_calendar_detail", "event-1")
|
||||
if err != nil || normalizeCalendarEvent(event)["eventId"] != "event-1" {
|
||||
t.Fatalf("event normalization: event=%#v err=%v", event, err)
|
||||
}
|
||||
if _, err := requireCalendarEvent(map[string]any{"success": true, "result": map[string]any{"summary": "missing id"}}, "calendar/get_calendar_detail", "event-1"); err == nil {
|
||||
t.Fatal("event without id unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarCreateReadsBackTerminalState(t *testing.T) {
|
||||
caller := &calendarCoverageCaller{responses: map[string][]string{
|
||||
"create_calendar_event": {`{"success":true,"result":{"id":"event-1"}}`},
|
||||
"get_calendar_detail": {`{"success":true,"result":{"id":"event-1","summary":"review","start":{"dateTime":"2026-03-10T14:00:00+08:00"},"end":{"dateTime":"2026-03-10T15:00:00+08:00"}}}`},
|
||||
}}
|
||||
err := runCalendarCoverage(t, EventCreate, caller,
|
||||
"--title", "review", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T15:00:00+08:00", "--yes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(caller.history, ","); got != "create_calendar_event,get_calendar_detail" {
|
||||
t.Fatalf("history=%s", got)
|
||||
}
|
||||
|
||||
noReceipt := &calendarCoverageCaller{responses: map[string][]string{"create_calendar_event": {`{"result":{"id":"event-2"}}`}}}
|
||||
err = runCalendarCoverage(t, EventCreate, noReceipt,
|
||||
"--title", "review", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T15:00:00+08:00", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "success=true") {
|
||||
t.Fatalf("non-terminal create error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarAlignedContracts(t *testing.T) {
|
||||
for _, declaration := range []shortcut.Shortcut{EventGet, EventCreate, EventUpdate, RSVP, EventSearch, Suggestion, RoomFind} {
|
||||
if declaration.Contract.Empty() || declaration.Contract.Result == nil {
|
||||
t.Errorf("%s missing contract/result", declaration.Command)
|
||||
}
|
||||
if strings.TrimSpace(declaration.Safety.Effect) == "" || strings.TrimSpace(declaration.Safety.Confirmation) == "" {
|
||||
t.Errorf("%s missing safety", declaration.Command)
|
||||
}
|
||||
if declaration.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s output rollout=%q", declaration.Command, declaration.OutputRollout)
|
||||
}
|
||||
}
|
||||
if EventSearch.Contract.Pagination == nil || EventList.Contract.Pagination == nil {
|
||||
t.Fatal("cursor commands must publish pagination contracts")
|
||||
}
|
||||
if EventCreate.Safety.Confirmation != "user_required" || EventUpdate.Safety.Confirmation != "user_required" || RSVP.Safety.Confirmation != "user_required" {
|
||||
t.Fatal("calendar writes must require confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarRoomFindPreservesPublishedFlags(t *testing.T) {
|
||||
flags := make(map[string]shortcut.Flag, len(RoomFind.Flags))
|
||||
for _, flag := range RoomFind.Flags {
|
||||
flags[flag.Name] = flag
|
||||
}
|
||||
if flag := flags["available"]; flag.Type != shortcut.FlagBool || flag.Hidden {
|
||||
t.Fatalf("available flag=%+v, want visible bool", flag)
|
||||
}
|
||||
for _, name := range []string{"limit", "page"} {
|
||||
if flag := flags[name]; flag.Type != shortcut.FlagString || flag.Hidden {
|
||||
t.Fatalf("%s flag=%+v, want visible string", name, flag)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &calendarCoverageCaller{responses: map[string][]string{
|
||||
"query_available_meeting_room": {`{"success":true,"result":{"rooms":[],"hasMore":false}}`},
|
||||
}}
|
||||
err := runCalendarCoverage(t, RoomFind, caller,
|
||||
"--start", "2026-03-10T14:00:00+08:00",
|
||||
"--end", "2026-03-10T15:00:00+08:00",
|
||||
"--available", "--limit", "25", "--page", "2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.arguments) != 1 {
|
||||
t.Fatalf("calls=%d, want 1", len(caller.arguments))
|
||||
}
|
||||
arguments := caller.arguments[0]
|
||||
if arguments["pageSize"] != "25" || arguments["pageIndex"] != "2" || arguments["needAvailable"] != true {
|
||||
t.Fatalf("arguments=%#v", arguments)
|
||||
}
|
||||
|
||||
invalid := &calendarCoverageCaller{responses: map[string][]string{}}
|
||||
if err := runCalendarCoverage(t, RoomFind, invalid, "--limit", "101"); err == nil || !strings.Contains(err.Error(), "1-100") {
|
||||
t.Fatalf("invalid limit error=%v", err)
|
||||
}
|
||||
if len(invalid.history) != 0 {
|
||||
t.Fatalf("invalid input made calls: %v", invalid.history)
|
||||
}
|
||||
|
||||
fixedNow := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
|
||||
defaultParams, err := calendarAgendaParams(calendarRuntimeForTest(t, EventList, nil), fixedNow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if defaultParams["startTime"] != time.Date(2026, time.August, 17, 0, 0, 0, 0, fixedNow.Location()).UnixMilli() ||
|
||||
defaultParams["endTime"] != time.Date(2026, time.August, 17, 23, 59, 59, 0, fixedNow.Location()).UnixMilli() {
|
||||
t.Fatalf("agenda default adapter arguments=%#v", defaultParams)
|
||||
}
|
||||
|
||||
allParams, err := calendarAgendaParams(calendarRuntimeForTest(t, EventList, map[string]string{
|
||||
"start": calendarCoverageStart,
|
||||
"end": calendarCoverageEnd,
|
||||
"calendar-id": "primary",
|
||||
"cursor": "cursor-1",
|
||||
"limit": "7",
|
||||
}), fixedNow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if allParams["calendarId"] != "primary" || allParams["cursor"] != "cursor-1" || allParams["limit"] != 7 {
|
||||
t.Fatalf("agenda optional adapter arguments=%#v", allParams)
|
||||
}
|
||||
|
||||
for name, values := range map[string]map[string]string{
|
||||
"invalid-start": {"start": "bad"},
|
||||
"invalid-end": {"end": "bad"},
|
||||
"reversed": {"start": calendarCoverageEnd, "end": calendarCoverageStart},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := calendarAgendaParams(calendarRuntimeForTest(t, EventList, values), fixedNow); err == nil {
|
||||
t.Fatal("invalid agenda adapter input was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarAgendaSchemaUsesExplicitRuntimeAdapter(t *testing.T) {
|
||||
properties := make(map[string]string, len(EventList.Contract.Parameters))
|
||||
for _, parameter := range EventList.Contract.Parameters {
|
||||
properties[parameter.Name] = parameter.Property
|
||||
}
|
||||
if properties["start"] != "start" || properties["end"] != "end" {
|
||||
t.Fatalf("agenda time properties=%#v", properties)
|
||||
}
|
||||
for _, flag := range EventList.Flags {
|
||||
if flag.Name == "limit" && flag.Default != "" {
|
||||
t.Fatalf("agenda limit default=%q, want published empty default", flag.Default)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &calendarCoverageCaller{responses: map[string][]string{
|
||||
"list_calendar_events": {`{"success":true,"result":{"events":[],"hasMore":false}}`},
|
||||
}}
|
||||
if err := runCalendarCoverage(t, EventList, caller,
|
||||
"--start", calendarCoverageStart,
|
||||
"--end", calendarCoverageEnd,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.arguments) != 1 {
|
||||
t.Fatalf("calls=%d, want 1", len(caller.arguments))
|
||||
}
|
||||
if _, ok := caller.arguments[0]["limit"]; ok {
|
||||
t.Fatalf("unset limit unexpectedly sent: %#v", caller.arguments[0])
|
||||
}
|
||||
start, err := parseMillis("start", calendarCoverageStart)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end, err := parseMillis("end", calendarCoverageEnd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.arguments[0]["startTime"] != start || caller.arguments[0]["endTime"] != end {
|
||||
t.Fatalf("agenda adapter arguments=%#v, published properties=%#v", caller.arguments[0], properties)
|
||||
}
|
||||
if _, leaked := caller.arguments[0][properties["start"]]; leaked {
|
||||
t.Fatalf("published composite property leaked into RPC arguments: %#v", caller.arguments[0])
|
||||
}
|
||||
if _, leaked := caller.arguments[0][properties["end"]]; leaked {
|
||||
t.Fatalf("published composite property leaked into RPC arguments: %#v", caller.arguments[0])
|
||||
}
|
||||
|
||||
invalid := &calendarCoverageCaller{responses: map[string][]string{}}
|
||||
if err := runCalendarCoverage(t, EventList, invalid, "--limit", "101"); err == nil || !strings.Contains(err.Error(), "1") {
|
||||
t.Fatalf("invalid limit error=%v", err)
|
||||
}
|
||||
if len(invalid.history) != 0 {
|
||||
t.Fatalf("invalid input made calls: %v", invalid.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarAttendeeProjectionAcceptsStableUserID(t *testing.T) {
|
||||
attendees, err := attendeeListProject(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"attendees": []any{map[string]any{
|
||||
"userId": "user-1",
|
||||
"responseStatus": "accepted",
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("userId-only attendee rejected: %v", err)
|
||||
}
|
||||
if len(attendees) != 1 || attendees[0]["userId"] != "user-1" {
|
||||
t.Fatalf("attendees=%#v", attendees)
|
||||
}
|
||||
|
||||
if _, err := attendeeListProject(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"attendees": []any{map[string]any{"responseStatus": "accepted"}},
|
||||
},
|
||||
}); err == nil {
|
||||
t.Fatal("attendee without displayName or userId was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
|
||||
|
||||
func calendarContract(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 := "calendar " + command
|
||||
return corecmd.ContractDecl{
|
||||
Description: description,
|
||||
Parameters: params,
|
||||
Result: result,
|
||||
Pagination: pagination,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: calendarCompositeReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{useWhen},
|
||||
AvoidWhen: avoidWhen,
|
||||
Examples: examples,
|
||||
},
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "calendar",
|
||||
Name: name,
|
||||
CanonicalPath: "calendar." + name,
|
||||
CLIPath: cliPath,
|
||||
PrimaryCLIPath: cliPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func calendarObjectResult(description string, outcomes ...contract.ResultOutcome) *contract.ResultSpec {
|
||||
if len(outcomes) == 0 {
|
||||
outcomes = []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
|
||||
}
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: outcomes,
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"additionalProperties":true}`,
|
||||
description,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func calendarCollectionResult(collection, description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"本页有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Calendar 业务条目","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果已完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`,
|
||||
description, collection, description, collection,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func calendarCursorPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "cursor",
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func calendarReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
|
||||
func calendarWriteSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
|
||||
}
|
||||
|
||||
func calendarResponseError(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 requireCalendarResponse(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, calendarResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功或结果确实为空")
|
||||
}
|
||||
for _, candidate := range calendarContainers(data) {
|
||||
value, present := candidate["success"]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
success, ok := value.(bool)
|
||||
if !ok {
|
||||
return nil, calendarResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
||||
}
|
||||
if !success {
|
||||
message := firstCalendarString(candidate, "errorMsg", "errorMessage", "message", "error")
|
||||
if message == "" {
|
||||
message = "服务明确返回 success=false"
|
||||
}
|
||||
return nil, calendarResponseError(operation, "remote_failure", message)
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireCalendarWriteResponse(data map[string]any, operation string) (map[string]any, error) {
|
||||
data, err := requireCalendarResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, candidate := range calendarContainers(data) {
|
||||
if success, ok := candidate["success"].(bool); ok && success {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
return nil, calendarResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据;远端效果未知")
|
||||
}
|
||||
|
||||
func calendarContainers(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
containers := []map[string]any{data}
|
||||
seen := map[mapIdentity]bool{}
|
||||
for depth, index := 0, 0; index < len(containers) && depth < 12; index, depth = index+1, depth+1 {
|
||||
candidate := containers[index]
|
||||
for _, key := range []string{"content", "result", "data", "event"} {
|
||||
if nested, ok := candidate[key].(map[string]any); ok {
|
||||
identity := mapIdentity{parent: index, key: key}
|
||||
if !seen[identity] {
|
||||
seen[identity] = true
|
||||
containers = append(containers, nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return containers
|
||||
}
|
||||
|
||||
type mapIdentity struct {
|
||||
parent int
|
||||
key string
|
||||
}
|
||||
|
||||
func requireCalendarObject(data map[string]any, operation string) (map[string]any, error) {
|
||||
data, err := requireCalendarResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
containers := calendarContainers(data)
|
||||
for index := len(containers) - 1; index >= 0; index-- {
|
||||
candidate := containers[index]
|
||||
if len(candidate) == 0 || calendarOnlyEnvelopeFields(candidate) {
|
||||
continue
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
return nil, calendarResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
|
||||
}
|
||||
|
||||
func calendarOnlyEnvelopeFields(candidate map[string]any) bool {
|
||||
for key := range candidate {
|
||||
switch key {
|
||||
case "success", "result", "data", "content", "message", "errorMsg", "errorMessage":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func requireCalendarCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
|
||||
data, err := requireCalendarResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, container := range calendarContainers(data) {
|
||||
for _, key := range keys {
|
||||
value, present := container[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
|
||||
}
|
||||
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return items, container, nil
|
||||
}
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if value, present := data[wrapper]; present {
|
||||
if items, ok := value.([]any); ok {
|
||||
if err := validateCalendarCollectionItems(items, operation, wrapper); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return items, data, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil, calendarResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段、内部错误或协议漂移投影成空结果")
|
||||
}
|
||||
|
||||
func validateCalendarCollectionItems(items []any, operation, key string) error {
|
||||
for index, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return calendarResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", key, index))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func projectCalendarRows(items []any, operation string, aliases map[string][]string, requiredAny ...string) ([]map[string]any, error) {
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for index, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
valid := len(requiredAny) == 0 && len(row) > 0
|
||||
for _, required := range requiredAny {
|
||||
if value, ok := row[required]; ok && !calendarEmptyValue(value) {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return nil, calendarResponseError(operation, "malformed_collection_item", fmt.Sprintf("业务数组第 %d 项缺少可识别的标识或时间字段", index))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func calendarEmptyValue(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
return strings.TrimSpace(text) == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type calendarPageEvidence struct {
|
||||
Known bool
|
||||
HasMore bool
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
func calendarPagination(container map[string]any, operation string) (calendarPageEvidence, error) {
|
||||
page := calendarPageEvidence{}
|
||||
if container == nil {
|
||||
return page, nil
|
||||
}
|
||||
if raw, present := container["hasMore"]; present {
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return page, calendarResponseError(operation, "malformed_pagination", "响应 hasMore 字段不是布尔值")
|
||||
}
|
||||
page.Known = true
|
||||
page.HasMore = value
|
||||
}
|
||||
for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
|
||||
raw, present := container[key]
|
||||
if !present || raw == nil {
|
||||
continue
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return page, calendarResponseError(operation, "malformed_pagination", fmt.Sprintf("响应 %s 字段不是字符串", key))
|
||||
}
|
||||
page.Known = true
|
||||
page.NextCursor = strings.TrimSpace(value)
|
||||
break
|
||||
}
|
||||
if page.HasMore && page.NextCursor == "" {
|
||||
return page, calendarResponseError(operation, "missing_next_cursor", "服务端返回 hasMore=true 但没有可续用的 nextCursor,结果不完整")
|
||||
}
|
||||
if page.Known && !page.HasMore && page.NextCursor != "" {
|
||||
return page, calendarResponseError(operation, "inconsistent_pagination", "服务端返回 hasMore=false 但仍携带非空 nextCursor")
|
||||
}
|
||||
if page.NextCursor != "" {
|
||||
page.HasMore = true
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func addCalendarPagination(out map[string]any, page calendarPageEvidence) {
|
||||
out["complete"] = page.Known && !page.HasMore
|
||||
if page.Known {
|
||||
out["hasMore"] = page.HasMore
|
||||
}
|
||||
if page.NextCursor != "" {
|
||||
out["nextCursor"] = page.NextCursor
|
||||
}
|
||||
}
|
||||
|
||||
// outputCalendarPage keeps legacy JSON compatibility while honoring the
|
||||
// unified Result contract: cursor controls move to meta.pagination and are not
|
||||
// duplicated into business data.
|
||||
func outputCalendarPage(rt *shortcut.RuntimeContext, payload map[string]any, page calendarPageEvidence) error {
|
||||
if !page.Known {
|
||||
return calendarResponseError("calendar/pagination", "missing_pagination", "响应缺少分页终态证据")
|
||||
}
|
||||
addCalendarPagination(payload, page)
|
||||
if !output.UsesUnifiedResult(rt.Command()) {
|
||||
return rt.Output(payload)
|
||||
}
|
||||
pagination, err := output.NewPagination(!page.HasMore, page.NextCursor)
|
||||
if err != nil {
|
||||
return calendarResponseError("calendar/pagination", "invalid_pagination", err.Error())
|
||||
}
|
||||
business := make(map[string]any, len(payload))
|
||||
for key, value := range payload {
|
||||
switch key {
|
||||
case "hasMore", "nextCursor":
|
||||
default:
|
||||
business[key] = value
|
||||
}
|
||||
}
|
||||
meta := &output.Meta{Pagination: pagination}
|
||||
if count, ok := payload["count"].(int); ok {
|
||||
meta.Count = output.NewCount(count)
|
||||
}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
|
||||
}
|
||||
|
||||
func firstCalendarString(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 nestedCalendarString(data map[string]any, keys ...string) string {
|
||||
for _, candidate := range calendarContainers(data) {
|
||||
if value := firstCalendarString(candidate, keys...); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateCalendarRange(startName, startValue, endName, endValue string) (time.Time, time.Time, error) {
|
||||
start, err := time.Parse(time.RFC3339, strings.TrimSpace(startValue))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, apperrors.NewValidation(fmt.Sprintf("--%s 时间格式无效,请使用 RFC3339/ISO-8601", startName))
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, strings.TrimSpace(endValue))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, apperrors.NewValidation(fmt.Sprintf("--%s 时间格式无效,请使用 RFC3339/ISO-8601", endName))
|
||||
}
|
||||
if !end.After(start) {
|
||||
return time.Time{}, time.Time{}, apperrors.NewValidation(fmt.Sprintf("--%s 必须晚于 --%s", endName, startName))
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func calendarStringList(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func calendarEventID(event map[string]any) string {
|
||||
return firstCalendarString(event, "eventId", "event_id", "id")
|
||||
}
|
||||
|
||||
func calendarTimeEquivalent(actual any, expected string) bool {
|
||||
want, err := time.Parse(time.RFC3339, expected)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if object, ok := actual.(map[string]any); ok {
|
||||
for _, key := range []string{"dateTime", "date_time", "value", "timestamp"} {
|
||||
if nested, exists := object[key]; exists {
|
||||
return calendarTimeEquivalent(nested, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch value := actual.(type) {
|
||||
case string:
|
||||
if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil {
|
||||
return parsed.Equal(want)
|
||||
}
|
||||
millis, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
return err == nil && time.UnixMilli(millis).Equal(want)
|
||||
case float64:
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0) && value == math.Trunc(value) && time.UnixMilli(int64(value)).Equal(want)
|
||||
case int64:
|
||||
return time.UnixMilli(value).Equal(want)
|
||||
case int:
|
||||
return time.UnixMilli(int64(value)).Equal(want)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func verifyCalendarEvent(event map[string]any, eventID string, requested map[string]any) error {
|
||||
if got := calendarEventID(event); got == "" || got != eventID {
|
||||
return calendarResponseError("calendar/get_calendar_detail", "readback_id_mismatch", "写后读回的 eventId 缺失或不一致")
|
||||
}
|
||||
aliases := map[string][]string{
|
||||
"summary": {"summary", "title", "name"},
|
||||
"description": {"description", "desc"},
|
||||
"startDateTime": {"startDateTime", "start_time", "startTime", "start"},
|
||||
"endDateTime": {"endDateTime", "end_time", "endTime", "end"},
|
||||
"timeZone": {"timeZone", "timezone", "time_zone"},
|
||||
"location": {"location", "locationName", "location_name"},
|
||||
"freeBusy": {"freeBusy", "free_busy"},
|
||||
}
|
||||
for property, expected := range requested {
|
||||
candidates := aliases[property]
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
var actual any
|
||||
var present bool
|
||||
for _, candidate := range candidates {
|
||||
actual, present = event[candidate]
|
||||
if present {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !present {
|
||||
return calendarResponseError("calendar/get_calendar_detail", "readback_field_missing", fmt.Sprintf("写后读回缺少字段 %s", property))
|
||||
}
|
||||
if property == "startDateTime" || property == "endDateTime" {
|
||||
if !calendarTimeEquivalent(actual, fmt.Sprint(expected)) {
|
||||
return calendarResponseError("calendar/get_calendar_detail", "readback_field_mismatch", fmt.Sprintf("写后读回字段 %s 与请求不一致", property))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(actual)) != strings.TrimSpace(fmt.Sprint(expected)) {
|
||||
return calendarResponseError("calendar/get_calendar_detail", "readback_field_mismatch", fmt.Sprintf("写后读回字段 %s 与请求不一致", property))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalizeCalendarShortcut(item *shortcut.Shortcut, result *contract.ResultSpec, pagination *contract.PaginationSpec) {
|
||||
item.OutputRollout = output.RolloutUnifiedActive
|
||||
item.Contract.Result = result
|
||||
item.Contract.Pagination = pagination
|
||||
item.Contract.Selection.AgentSummary = item.Description
|
||||
item.Contract.Selection.UseWhen = []string{item.Intent}
|
||||
if item.Contract.Interface == nil {
|
||||
item.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: calendarCompositeReason}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func calendarReadShortcut(command, description, intent, collection string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
result := calendarObjectResult(description)
|
||||
if collection != "" {
|
||||
result = calendarCollectionResult(collection, description)
|
||||
}
|
||||
return shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "calendar",
|
||||
Command: command,
|
||||
Product: "calendar",
|
||||
Description: description,
|
||||
Intent: intent,
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: calendarReadSafety(),
|
||||
Contract: calendarContract(command, description, intent,
|
||||
[]string{"需要未公开底层参数或原始 MCP 响应时改用对应原子命令;缺失业务对象或数组不是合法空结果"},
|
||||
[]string{calendarExample(command)}, result, nil, params...),
|
||||
Flags: flags,
|
||||
Execute: execute,
|
||||
}
|
||||
}
|
||||
|
||||
func calendarWriteShortcut(command, description, intent, example string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "calendar",
|
||||
Command: command,
|
||||
Product: "calendar",
|
||||
Description: description,
|
||||
Intent: intent,
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: calendarWriteSafety(),
|
||||
Contract: calendarContract(command, description, intent,
|
||||
[]string{"只需查看日程或尚未确认目标 eventId/影响范围时不要执行写操作"},
|
||||
[]string{example}, calendarObjectResult(description, contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure), nil, params...),
|
||||
Flags: flags,
|
||||
Execute: execute,
|
||||
}
|
||||
}
|
||||
|
||||
func calendarExample(command string) string {
|
||||
switch command {
|
||||
case "+get":
|
||||
return "dws calendar +get --event <EVENT_ID> --format json"
|
||||
case "+search-event":
|
||||
return `dws calendar +search-event --query "评审" --format json`
|
||||
case "+suggestion":
|
||||
return "dws calendar +suggestion --users <USER_ID> --duration 30 --format json"
|
||||
default:
|
||||
return "dws calendar " + command + " --format json"
|
||||
}
|
||||
}
|
||||
|
||||
// EventGet is the strict shortcut counterpart of calendar event get. A missing
|
||||
// result object and a result object without an id are protocol failures.
|
||||
var EventGet = calendarReadShortcut(
|
||||
"+get", "严格获取日程详情",
|
||||
"已知 eventId 时读取完整日程;把后端稳定 id 规范化为 eventId,并拒绝空响应、缺失对象或错误对象。",
|
||||
"",
|
||||
[]shortcut.Flag{
|
||||
{Name: "event", Type: shortcut.FlagString, Required: true, Desc: "日程 eventId"},
|
||||
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID(默认 primary)"},
|
||||
},
|
||||
[]contract.ParamDecl{{Name: "event", Property: "eventId"}, {Name: "calendar-id", Property: "calendarId"}},
|
||||
func(rt *shortcut.RuntimeContext) error {
|
||||
params := calendarEventTarget(rt)
|
||||
data, err := rt.CallMCPData("calendar", "get_calendar_detail", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, err := requireCalendarEvent(data, "calendar/get_calendar_detail", rt.Str("event"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"event": normalizeCalendarEvent(event)})
|
||||
},
|
||||
)
|
||||
|
||||
// EventCreate creates one event and requires an event id plus a successful
|
||||
// get_calendar_detail read-back before reporting success.
|
||||
var EventCreate = calendarWriteShortcut(
|
||||
"+create", "创建日程并读回验证",
|
||||
"创建单次日程,可同时传 userId 参会人和 roomId;只有创建响应给出 eventId 且详情读回与标题、起止时间一致时才报告成功。",
|
||||
`dws calendar +create --title "项目评审" --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json`,
|
||||
[]shortcut.Flag{
|
||||
{Name: "title", Type: shortcut.FlagString, Required: true, Desc: "日程标题,最多 2048 字符"},
|
||||
{Name: "start", Type: shortcut.FlagString, Required: true, Desc: "开始时间 RFC3339/ISO-8601"},
|
||||
{Name: "end", Type: shortcut.FlagString, Required: true, Desc: "结束时间 RFC3339/ISO-8601"},
|
||||
{Name: "desc", Type: shortcut.FlagString, Desc: "日程描述,最多 5000 字符"},
|
||||
{Name: "attendees", Type: shortcut.FlagStringSlice, Desc: "参会人 userId,最多 500 个"},
|
||||
{Name: "rooms", Type: shortcut.FlagStringSlice, Desc: "会议室 roomId"},
|
||||
{Name: "timezone", Type: shortcut.FlagString, Desc: "IANA 时区,如 Asia/Shanghai"},
|
||||
{Name: "location", Type: shortcut.FlagString, Desc: "地点文本"},
|
||||
{Name: "free-busy", Type: shortcut.FlagString, Desc: "忙闲状态", Enum: []string{"busy", "free"}},
|
||||
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "目标日历 ID(默认 primary)"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "title", Property: "summary"}, {Name: "start", Property: "startDateTime"}, {Name: "end", Property: "endDateTime"},
|
||||
{Name: "desc", Property: "description"}, {Name: "attendees", Property: "attendees"}, {Name: "rooms", Property: "roomIds"},
|
||||
{Name: "timezone", Property: "timeZone"}, {Name: "location", Property: "location"}, {Name: "free-busy", Property: "freeBusy"},
|
||||
{Name: "calendar-id", Property: "calendarId"},
|
||||
},
|
||||
func(rt *shortcut.RuntimeContext) error {
|
||||
params, verify, err := calendarCreateParams(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "calendar/create_calendar_event", "arguments": params})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("calendar", "create_calendar_event", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireCalendarWriteResponse(written, "calendar/create_calendar_event")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventID := nestedCalendarString(written, "eventId", "event_id", "id")
|
||||
if eventID == "" {
|
||||
return calendarResponseError("calendar/create_calendar_event", "missing_created_id", "创建响应没有 eventId/id;远端效果未知")
|
||||
}
|
||||
readParams := map[string]any{"eventId": eventID}
|
||||
if calendarID := rt.Str("calendar-id"); calendarID != "" {
|
||||
readParams["calendarId"] = calendarID
|
||||
}
|
||||
readback, err := rt.CallMCPData("calendar", "get_calendar_detail", readParams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, err := requireCalendarEvent(readback, "calendar/get_calendar_detail", eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verifyCalendarEvent(event, eventID, verify); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "eventId": eventID, "verified": true, "event": normalizeCalendarEvent(event)})
|
||||
},
|
||||
)
|
||||
|
||||
// EventUpdate applies event fields and attendee changes as explicit stages.
|
||||
// It never rolls back or hides a later-stage failure; errors name completed
|
||||
// stages so callers can safely inspect state before retrying.
|
||||
var EventUpdate = calendarWriteShortcut(
|
||||
"+update", "分阶段更新日程并读回验证",
|
||||
"已知 eventId 时更新标题、描述、完整起止时间或参会人;多阶段写不是事务,任一步失败都会列出已经完成的步骤,成功则读回字段和参会人集合。",
|
||||
`dws calendar +update --event <EVENT_ID> --title "新标题" --format json`,
|
||||
[]shortcut.Flag{
|
||||
{Name: "event", Type: shortcut.FlagString, Required: true, Desc: "日程 eventId"},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "新标题"},
|
||||
{Name: "desc", Type: shortcut.FlagString, Desc: "新描述"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "新开始时间;必须与 --end 同时传"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "新结束时间;必须与 --start 同时传"},
|
||||
{Name: "timezone", Type: shortcut.FlagString, Desc: "IANA 时区"},
|
||||
{Name: "location", Type: shortcut.FlagString, Desc: "新地点文本"},
|
||||
{Name: "free-busy", Type: shortcut.FlagString, Desc: "忙闲状态", Enum: []string{"busy", "free"}},
|
||||
{Name: "add-attendees", Type: shortcut.FlagStringSlice, Desc: "新增参会人 userId"},
|
||||
{Name: "remove-attendees", Type: shortcut.FlagStringSlice, Desc: "移除参会人 userId"},
|
||||
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID(默认 primary)"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "event", Property: "eventId"}, {Name: "title", Property: "summary"}, {Name: "desc", Property: "description"},
|
||||
{Name: "start", Property: "startDateTime"}, {Name: "end", Property: "endDateTime"}, {Name: "timezone", Property: "timeZone"},
|
||||
{Name: "location", Property: "location"}, {Name: "free-busy", Property: "freeBusy"},
|
||||
{Name: "add-attendees", Property: "attendeesToAdd"}, {Name: "remove-attendees", Property: "attendeesToRemove"},
|
||||
{Name: "calendar-id", Property: "calendarId"},
|
||||
},
|
||||
func(rt *shortcut.RuntimeContext) error {
|
||||
fields, add, remove, err := calendarUpdateParams(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := calendarEventTarget(rt)
|
||||
preflight, err := rt.CallMCPData("calendar", "get_calendar_detail", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireCalendarEvent(preflight, "calendar/get_calendar_detail", rt.Str("event")); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "calendar/update", "eventId": rt.Str("event"), "fields": fields, "addAttendees": add, "removeAttendees": remove})
|
||||
}
|
||||
completed := make([]string, 0, 3)
|
||||
if len(fields) > 0 {
|
||||
params := calendarEventTarget(rt)
|
||||
for key, value := range fields {
|
||||
params[key] = value
|
||||
}
|
||||
written, callErr := rt.CallMCPWriteDataStrict("calendar", "update_calendar_event", params)
|
||||
if callErr != nil {
|
||||
return calendarStageError("update_event", completed, callErr)
|
||||
}
|
||||
if _, callErr = requireCalendarWriteResponse(written, "calendar/update_calendar_event"); callErr != nil {
|
||||
return calendarStageError("update_event", completed, callErr)
|
||||
}
|
||||
completed = append(completed, "update_event")
|
||||
}
|
||||
if len(remove) > 0 {
|
||||
params := calendarEventTarget(rt)
|
||||
params["attendeesToRemove"] = remove
|
||||
written, callErr := rt.CallMCPWriteDataStrict("calendar", "remove_calendar_participant", params)
|
||||
if callErr != nil {
|
||||
return calendarStageError("remove_attendees", completed, callErr)
|
||||
}
|
||||
if _, callErr = requireCalendarWriteResponse(written, "calendar/remove_calendar_participant"); callErr != nil {
|
||||
return calendarStageError("remove_attendees", completed, callErr)
|
||||
}
|
||||
completed = append(completed, "remove_attendees")
|
||||
}
|
||||
if len(add) > 0 {
|
||||
params := calendarEventTarget(rt)
|
||||
params["attendeesToAdd"] = add
|
||||
written, callErr := rt.CallMCPWriteDataStrict("calendar", "add_calendar_participant", params)
|
||||
if callErr != nil {
|
||||
return calendarStageError("add_attendees", completed, callErr)
|
||||
}
|
||||
if _, callErr = requireCalendarWriteResponse(written, "calendar/add_calendar_participant"); callErr != nil {
|
||||
return calendarStageError("add_attendees", completed, callErr)
|
||||
}
|
||||
completed = append(completed, "add_attendees")
|
||||
}
|
||||
readback, err := rt.CallMCPData("calendar", "get_calendar_detail", target)
|
||||
if err != nil {
|
||||
return calendarStageError("verify_event", completed, err)
|
||||
}
|
||||
event, err := requireCalendarEvent(readback, "calendar/get_calendar_detail", rt.Str("event"))
|
||||
if err != nil {
|
||||
return calendarStageError("verify_event", completed, err)
|
||||
}
|
||||
if err := verifyCalendarEvent(event, rt.Str("event"), fields); err != nil {
|
||||
return calendarStageError("verify_event", completed, err)
|
||||
}
|
||||
if len(add)+len(remove) > 0 {
|
||||
participants, callErr := rt.CallMCPData("calendar", "get_calendar_participants", target)
|
||||
if callErr != nil {
|
||||
return calendarStageError("verify_attendees", completed, callErr)
|
||||
}
|
||||
rows, callErr := attendeeListProject(participants)
|
||||
if callErr != nil {
|
||||
return calendarStageError("verify_attendees", completed, callErr)
|
||||
}
|
||||
if callErr := verifyCalendarAttendees(rows, add, remove); callErr != nil {
|
||||
return calendarStageError("verify_attendees", completed, callErr)
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "eventId": rt.Str("event"), "completedSteps": completed, "verified": true, "event": normalizeCalendarEvent(event)})
|
||||
},
|
||||
)
|
||||
|
||||
// RSVP maps familiar verbs onto the DingTalk responseStatus values. It always
|
||||
// reads the event after the write; responseStatus is verified when the backend
|
||||
// exposes it, otherwise success is explicitly labelled terminal-response-only.
|
||||
var RSVP = calendarWriteShortcut(
|
||||
"+rsvp", "响应日程邀请并读取终态",
|
||||
"作为当前参会人接受、拒绝或暂定一个日程邀请;写响应必须非空且非失败,并在写后读取日程,绝不把空 ack 当成功。",
|
||||
"dws calendar +rsvp --event <EVENT_ID> --status accept --format json",
|
||||
[]shortcut.Flag{
|
||||
{Name: "event", Type: shortcut.FlagString, Required: true, Desc: "日程 eventId"},
|
||||
{Name: "status", Type: shortcut.FlagString, Required: true, Desc: "响应动作", Enum: []string{"accept", "decline", "tentative", "needs-action"}},
|
||||
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID(默认 primary)"},
|
||||
},
|
||||
[]contract.ParamDecl{{Name: "event", Property: "eventId"}, {Name: "status", Property: "responseStatus"}, {Name: "calendar-id", Property: "calendarId"}},
|
||||
func(rt *shortcut.RuntimeContext) error {
|
||||
status := map[string]string{"accept": "accepted", "decline": "declined", "tentative": "tentative", "needs-action": "needsAction"}[rt.Str("status")]
|
||||
target := calendarEventTarget(rt)
|
||||
preflight, err := rt.CallMCPData("calendar", "get_calendar_detail", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireCalendarEvent(preflight, "calendar/get_calendar_detail", rt.Str("event")); err != nil {
|
||||
return err
|
||||
}
|
||||
params := calendarEventTarget(rt)
|
||||
params["responseStatus"] = status
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "calendar/respond", "arguments": params})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("calendar", "respond", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireCalendarWriteResponse(written, "calendar/respond")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readback, err := rt.CallMCPData("calendar", "get_calendar_detail", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, err := requireCalendarEvent(readback, "calendar/get_calendar_detail", rt.Str("event"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readStatus := firstCalendarString(event, "responseStatus", "response_status")
|
||||
verified := readStatus == status
|
||||
if readStatus != "" && !verified {
|
||||
return calendarResponseError("calendar/respond", "readback_status_mismatch", "响应日程后读回的 responseStatus 与请求不一致")
|
||||
}
|
||||
if !verified {
|
||||
writeStatus := nestedCalendarString(written, "responseStatus", "response_status", "status")
|
||||
if writeStatus != "" && writeStatus != status {
|
||||
return calendarResponseError("calendar/respond", "terminal_status_mismatch", "响应接口返回的状态与请求不一致")
|
||||
}
|
||||
if writeStatus == status {
|
||||
verified = true
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "eventId": rt.Str("event"), "responseStatus": status, "verified": verified, "verification": map[string]any{"eventReadback": true, "statusReadback": readStatus != ""}})
|
||||
},
|
||||
)
|
||||
|
||||
// EventSearch is an explicitly page-local client-side search. It returns the
|
||||
// upstream cursor and complete=false whenever another page may exist.
|
||||
var EventSearch = calendarReadShortcut(
|
||||
"+search-event", "在一个日程页内严格搜索事件",
|
||||
"DingTalk 没有专用日程全文搜索接口;该命令在 list_calendar_events 当前页按标题、描述和地点过滤,并原样保留 nextCursor/hasMore,绝不把单页零命中说成全局未找到。",
|
||||
"events",
|
||||
[]shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "标题、描述或地点关键词"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间,默认今天 00:00"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间,默认今天 23:59:59"},
|
||||
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID(默认 primary)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "上一页 nextCursor"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "单页条数 1-100"},
|
||||
},
|
||||
[]contract.ParamDecl{{Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, {Name: "calendar-id", Property: "calendarId"}, {Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}},
|
||||
func(rt *shortcut.RuntimeContext) error {
|
||||
params, err := calendarListParams(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, container, err := requireCalendarCollection(data, "calendar/list_calendar_events", "events")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := strings.ToLower(rt.Str("query"))
|
||||
matched := make([]any, 0)
|
||||
for _, item := range items {
|
||||
object := item.(map[string]any)
|
||||
text := strings.ToLower(strings.Join([]string{
|
||||
firstCalendarString(object, "summary", "title", "name"),
|
||||
firstCalendarString(object, "description", "desc"),
|
||||
firstCalendarString(object, "location", "locationName", "location_name"),
|
||||
}, "\n"))
|
||||
if strings.Contains(text, query) {
|
||||
matched = append(matched, item)
|
||||
}
|
||||
}
|
||||
events, err := projectCalendarRows(matched, "calendar/list_calendar_events", map[string][]string{
|
||||
"eventId": {"eventId", "event_id", "id"}, "summary": {"summary", "title", "name"},
|
||||
"start": {"start", "startTime", "startDateTime"}, "end": {"end", "endTime", "endDateTime"},
|
||||
"status": {"status", "eventStatus"}, "location": {"location", "locationName"},
|
||||
}, "eventId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := calendarPagination(container, "calendar/list_calendar_events")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !page.Known {
|
||||
return calendarResponseError("calendar/list_calendar_events", "missing_pagination", "日程搜索响应缺少 hasMore/nextCursor 分页证据,不能把当前页当作完整搜索结果")
|
||||
}
|
||||
out := map[string]any{"count": len(events), "events": events, "query": rt.Str("query")}
|
||||
return outputCalendarPage(rt, out, page)
|
||||
},
|
||||
)
|
||||
|
||||
// Suggestion exposes the direct-user-id suggested-time route. Name resolution
|
||||
// remains the distinct +suggest-time shortcut.
|
||||
var Suggestion = calendarReadShortcut(
|
||||
"+suggestion", "按 userId 严格推荐共同空闲时间",
|
||||
"已经知道参与者 userId 时直接查询共同可用时间;显式 recommendEventTimes:[] 才表示没有建议,缺字段或坏元素会失败。按姓名解析请用 +suggest-time。",
|
||||
"suggestions",
|
||||
[]shortcut.Flag{
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Required: true, Desc: "参与者 userId"},
|
||||
{Name: "duration", Type: shortcut.FlagInt, Default: "30", Desc: "会议时长分钟数 1-1440"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "推荐范围开始;与 --end 同时传"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "推荐范围结束;与 --start 同时传"},
|
||||
{Name: "timezone", Type: shortcut.FlagString, Desc: "IANA 时区"},
|
||||
},
|
||||
[]contract.ParamDecl{{Name: "users", Property: "attendeeUserIds"}, {Name: "duration", Property: "durationMinutes"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "timezone", Property: "timeZone"}},
|
||||
func(rt *shortcut.RuntimeContext) error {
|
||||
users := calendarStringList(rt.StrSlice("users"))
|
||||
if len(users) == 0 {
|
||||
return apperrors.NewValidation("--users 至少包含一个非空 userId")
|
||||
}
|
||||
if rt.Int("duration") < 1 || rt.Int("duration") > 1440 {
|
||||
return apperrors.NewValidation("--duration 必须在 1-1440 分钟之间")
|
||||
}
|
||||
if rt.Changed("start") != rt.Changed("end") {
|
||||
return apperrors.NewValidation("--start 与 --end 必须同时传入")
|
||||
}
|
||||
params := map[string]any{"attendeeUserIds": users, "durationMinutes": fmt.Sprint(rt.Int("duration"))}
|
||||
if rt.Changed("start") {
|
||||
if _, _, err := validateCalendarRange("start", rt.Str("start"), "end", rt.Str("end")); err != nil {
|
||||
return err
|
||||
}
|
||||
params["start"] = rt.Str("start")
|
||||
params["end"] = rt.Str("end")
|
||||
}
|
||||
if rt.Changed("timezone") {
|
||||
params["timeZone"] = rt.Str("timezone")
|
||||
}
|
||||
data, err := rt.CallMCPData("calendar", "list_suggested_event_times", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _, err := requireCalendarCollection(data, "calendar/list_suggested_event_times", "recommendEventTimes", "suggestions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suggestions, err := projectCalendarRows(items, "calendar/list_suggested_event_times", map[string][]string{
|
||||
"start": {"startTime", "start", "startDateTime"}, "end": {"endTime", "end", "endDateTime"},
|
||||
"conflicts": {"timeConflictAttendees", "conflicts"},
|
||||
}, "start")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for index, suggestion := range suggestions {
|
||||
if calendarEmptyValue(suggestion["end"]) {
|
||||
return calendarResponseError("calendar/list_suggested_event_times", "malformed_collection_item", fmt.Sprintf("建议时段第 %d 项缺少结束时间", index))
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(suggestions), "suggestions": suggestions, "complete": true})
|
||||
},
|
||||
)
|
||||
|
||||
func calendarEventTarget(rt *shortcut.RuntimeContext) map[string]any {
|
||||
params := map[string]any{"eventId": rt.Str("event")}
|
||||
if calendarID := rt.Str("calendar-id"); calendarID != "" {
|
||||
params["calendarId"] = calendarID
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func requireCalendarEvent(data map[string]any, operation, expectedID string) (map[string]any, error) {
|
||||
event, err := requireCalendarObject(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eventID := calendarEventID(event)
|
||||
if eventID == "" {
|
||||
return nil, calendarResponseError(operation, "missing_event_id", "日程对象缺少 id/eventId")
|
||||
}
|
||||
if expectedID != "" && eventID != expectedID {
|
||||
return nil, calendarResponseError(operation, "event_id_mismatch", "日程对象 id 与请求 eventId 不一致")
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func normalizeCalendarEvent(event map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(event)+1)
|
||||
for key, value := range event {
|
||||
out[key] = value
|
||||
}
|
||||
out["eventId"] = calendarEventID(event)
|
||||
return out
|
||||
}
|
||||
|
||||
func calendarCreateParams(rt *shortcut.RuntimeContext) (map[string]any, map[string]any, error) {
|
||||
if len([]rune(rt.Str("title"))) > 2048 {
|
||||
return nil, nil, apperrors.NewValidation("--title 不能超过 2048 个字符")
|
||||
}
|
||||
if len([]rune(rt.Str("desc"))) > 5000 {
|
||||
return nil, nil, apperrors.NewValidation("--desc 不能超过 5000 个字符")
|
||||
}
|
||||
if _, _, err := validateCalendarRange("start", rt.Str("start"), "end", rt.Str("end")); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
attendees := calendarStringList(rt.StrSlice("attendees"))
|
||||
if len(attendees) > 500 {
|
||||
return nil, nil, apperrors.NewValidation("--attendees 最多 500 个 userId")
|
||||
}
|
||||
params := map[string]any{"summary": rt.Str("title"), "startDateTime": rt.Str("start"), "endDateTime": rt.Str("end")}
|
||||
verify := map[string]any{"summary": rt.Str("title"), "startDateTime": rt.Str("start"), "endDateTime": rt.Str("end")}
|
||||
for _, pair := range [][2]string{{"desc", "description"}, {"timezone", "timeZone"}, {"location", "location"}, {"free-busy", "freeBusy"}, {"calendar-id", "calendarId"}} {
|
||||
if rt.Changed(pair[0]) {
|
||||
params[pair[1]] = rt.Str(pair[0])
|
||||
if pair[1] != "calendarId" {
|
||||
verify[pair[1]] = rt.Str(pair[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(attendees) > 0 {
|
||||
params["attendees"] = attendees
|
||||
}
|
||||
if rooms := calendarStringList(rt.StrSlice("rooms")); len(rooms) > 0 {
|
||||
params["roomIds"] = rooms
|
||||
}
|
||||
return params, verify, nil
|
||||
}
|
||||
|
||||
func calendarUpdateParams(rt *shortcut.RuntimeContext) (map[string]any, []string, []string, error) {
|
||||
if rt.Changed("start") != rt.Changed("end") {
|
||||
return nil, nil, nil, apperrors.NewValidation("--start 与 --end 必须同时传入")
|
||||
}
|
||||
if rt.Changed("start") {
|
||||
if _, _, err := validateCalendarRange("start", rt.Str("start"), "end", rt.Str("end")); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
fields := map[string]any{}
|
||||
for _, pair := range [][2]string{{"title", "summary"}, {"desc", "description"}, {"start", "startDateTime"}, {"end", "endDateTime"}, {"timezone", "timeZone"}, {"location", "location"}, {"free-busy", "freeBusy"}} {
|
||||
if rt.Changed(pair[0]) {
|
||||
fields[pair[1]] = rt.Str(pair[0])
|
||||
}
|
||||
}
|
||||
add := calendarStringList(rt.StrSlice("add-attendees"))
|
||||
remove := calendarStringList(rt.StrSlice("remove-attendees"))
|
||||
if len(fields)+len(add)+len(remove) == 0 {
|
||||
return nil, nil, nil, apperrors.NewValidation("至少提供一个要更新的字段或参会人变更")
|
||||
}
|
||||
removeSet := map[string]bool{}
|
||||
for _, id := range remove {
|
||||
removeSet[id] = true
|
||||
}
|
||||
for _, id := range add {
|
||||
if removeSet[id] {
|
||||
return nil, nil, nil, apperrors.NewValidation("同一 userId 不能同时出现在 --add-attendees 与 --remove-attendees")
|
||||
}
|
||||
}
|
||||
return fields, add, remove, nil
|
||||
}
|
||||
|
||||
func verifyCalendarAttendees(rows []map[string]any, added, removed []string) error {
|
||||
present := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
if id := strings.TrimSpace(fmt.Sprint(row["userId"])); id != "" {
|
||||
present[id] = true
|
||||
}
|
||||
}
|
||||
for _, id := range added {
|
||||
if !present[id] {
|
||||
return calendarResponseError("calendar/get_calendar_participants", "readback_attendee_missing", "写后读回未找到新增参会人")
|
||||
}
|
||||
}
|
||||
for _, id := range removed {
|
||||
if present[id] {
|
||||
return calendarResponseError("calendar/get_calendar_participants", "readback_attendee_present", "写后读回仍包含已移除参会人")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func calendarStageError(stage string, completed []string, cause error) error {
|
||||
message := fmt.Sprintf("日程更新在步骤 %s 失败", stage)
|
||||
if len(completed) > 0 {
|
||||
message += ";此前已完成且不会自动回滚的步骤: " + strings.Join(completed, ",")
|
||||
}
|
||||
if cause != nil {
|
||||
message += ": " + cause.Error()
|
||||
}
|
||||
return calendarResponseError("calendar/update", "partial_or_unknown_effect", message)
|
||||
}
|
||||
|
||||
func calendarListParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
if rt.Int("limit") < 1 || rt.Int("limit") > 100 {
|
||||
return nil, apperrors.NewValidation("--limit 必须在 1-100 之间")
|
||||
}
|
||||
now := time.Now()
|
||||
start := rt.Str("start")
|
||||
end := rt.Str("end")
|
||||
if start == "" {
|
||||
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format(time.RFC3339)
|
||||
}
|
||||
if end == "" {
|
||||
end = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Format(time.RFC3339)
|
||||
}
|
||||
startTime, endTime, err := validateCalendarRange("start", start, "end", end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]any{"startTime": startTime.UnixMilli(), "endTime": endTime.UnixMilli(), "limit": rt.Int("limit")}
|
||||
if value := rt.Str("calendar-id"); value != "" {
|
||||
params["calendarId"] = value
|
||||
}
|
||||
if value := rt.Str("cursor"); value != "" {
|
||||
params["cursor"] = value
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// list_meeting_room_groups nests the groups under result.groupList; the resolver
|
||||
// must probe "groupList" or +room-groups silently returns empty despite the
|
||||
// backend returning meeting-room groups.
|
||||
func TestRoomGroupsProjectGroupListShape(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRoomGroupsProjectGroupListShape(t *testing.T) {
|
||||
const raw = `{"result":{"groupList":[
|
||||
{"groupId":"g1","groupName":"north rooms"},
|
||||
{"groupId":"g2","groupName":"south rooms"}
|
||||
@@ -31,7 +31,11 @@ func TestRoomGroupsProjectGroupListShape(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if got := roomGroupsProject(data); len(got) != 2 {
|
||||
got, err := roomGroupsProject(data)
|
||||
if err != nil {
|
||||
t.Fatalf("projection: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.groupList has 2 entries, projection returned %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,12 @@ var MessagesSendByBot = shortcut.Shortcut{
|
||||
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true, Aliases: []string{"text"}},
|
||||
{Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId 列表"},
|
||||
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId 列表"},
|
||||
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"},
|
||||
},
|
||||
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --text "## 今日完成"`},
|
||||
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --content "## 今日完成"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
|
||||
return err
|
||||
@@ -63,7 +63,7 @@ var MessagesSendByBot = shortcut.Shortcut{
|
||||
"robotCode": rt.Str("robot-code"),
|
||||
"openConversationId": rt.Str("group"),
|
||||
"title": rt.Str("title"),
|
||||
"markdown": rt.Str("text"),
|
||||
"markdown": rt.StrFirst("text", "content"),
|
||||
}
|
||||
if v := rt.StrSlice("at-user-ids"); len(v) > 0 {
|
||||
params["atUserIds"] = v
|
||||
@@ -90,12 +90,12 @@ var MessagesBatchSendByBot = shortcut.Shortcut{
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true, Aliases: []string{"text"}},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 userId 列表"},
|
||||
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表"},
|
||||
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"},
|
||||
},
|
||||
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --text "请提交周报"`},
|
||||
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --content "请提交周报"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateExplicitOpenIDs("--open-dingtalk-ids", rt.StrSlice("open-dingtalk-ids")); err != nil {
|
||||
return err
|
||||
@@ -103,7 +103,7 @@ var MessagesBatchSendByBot = shortcut.Shortcut{
|
||||
params := map[string]any{
|
||||
"robotCode": rt.Str("robot-code"),
|
||||
"title": rt.Str("title"),
|
||||
"markdown": rt.Str("text"),
|
||||
"markdown": rt.StrFirst("text", "content"),
|
||||
}
|
||||
if v := rt.StrSlice("users"); len(v) > 0 {
|
||||
params["userIds"] = v
|
||||
@@ -148,23 +148,23 @@ var MessagesSendByWebhook = shortcut.Shortcut{
|
||||
AgentSummary: "兼容旧入口的自定义机器人 Webhook 群消息发送",
|
||||
UseWhen: []string{"只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +messages-send-by-webhook --token <token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
|
||||
Examples: []string{"dws chat +messages-send-by-webhook --token <token> --title \"告警\" --content \"CPU 超 90%\" --at-all"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "token", Type: shortcut.FlagString, Desc: "Webhook token", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "消息正文", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "消息正文", Required: true, Aliases: []string{"text"}},
|
||||
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"},
|
||||
{Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号列表"},
|
||||
{Name: "at-users", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId 列表"},
|
||||
},
|
||||
Tips: []string{`dws chat +messages-send-by-webhook --token <token> --title "告警" --text "CPU 超 90%" --at-all`},
|
||||
Tips: []string{`dws chat +messages-send-by-webhook --token <token> --title "告警" --content "CPU 超 90%" --at-all`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"robotToken": rt.Str("token"),
|
||||
"title": rt.Str("title"),
|
||||
"text": rt.Str("text"),
|
||||
"text": rt.StrFirst("text", "content"),
|
||||
}
|
||||
if rt.Bool("at-all") {
|
||||
params["isAtAll"] = true
|
||||
|
||||
@@ -200,11 +200,11 @@ var MessagesReply = shortcut.Shortcut{
|
||||
Intent: "当你要以当前用户身份对一条已有消息发送纯文本引用回复时使用;传会话和原消息 ID,CLI 会先读取原发送者,也可显式传 --ref-sender。成功结果在保留下层响应的同时增量返回 messageId(下层提供时)、conversationId、threadId(适用时)、deliveryStatus、idempotencyKey 和 referencedMessage 来源上下文。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true, Aliases: []string{"conversation-id"}},
|
||||
{Name: "ref-msg-id", Type: shortcut.FlagString, Desc: "被引用消息 openMessageId"},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "--ref-msg-id 的 lark-cli 对齐别名"},
|
||||
{Name: "ref-sender", Type: shortcut.FlagString, Desc: "原消息发送者 openDingTalkId/userId(userId 通过通讯录搜索精确匹配;不传则自动读取)"},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本回复内容", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "纯文本回复内容", Required: true, Aliases: []string{"text"}},
|
||||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"},
|
||||
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名"},
|
||||
shortcut.AIMessageTagFlag(),
|
||||
@@ -213,7 +213,7 @@ var MessagesReply = shortcut.Shortcut{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"ref-msg-id", "message-id"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
|
||||
},
|
||||
Tips: []string{`dws chat +messages-reply --conversation-id <openConversationId> --message-id <openMessageId> --text "收到" --idempotency-key <key>`},
|
||||
Tips: []string{`dws chat +messages-reply --group <openConversationId> --message-id <openMessageId> --content "收到" --idempotency-key <key>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
refSender, err := resolveReplySender(rt)
|
||||
if err != nil {
|
||||
@@ -223,10 +223,10 @@ var MessagesReply = shortcut.Shortcut{
|
||||
"referenceOpenMessageId": replyMessageID(rt),
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": rt.Str("text"),
|
||||
"content": rt.StrFirst("text", "content"),
|
||||
})
|
||||
params := rt.AddAIMessageTag(map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"openConversationId": replyConversationID(rt),
|
||||
"msgType": "reply",
|
||||
"content": string(content),
|
||||
})
|
||||
@@ -240,7 +240,7 @@ var MessagesReply = shortcut.Shortcut{
|
||||
"willSend": false,
|
||||
"transport": "chat/send_personal_message",
|
||||
"arguments": params,
|
||||
"conversationId": rt.Str("conversation-id"),
|
||||
"conversationId": replyConversationID(rt),
|
||||
"referencedMessage": map[string]any{
|
||||
"messageId": replyMessageID(rt),
|
||||
"senderOpenDingTalkId": refSender,
|
||||
@@ -256,9 +256,13 @@ var MessagesReply = shortcut.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func replyConversationID(rt *shortcut.RuntimeContext) string {
|
||||
return rt.StrFirst("conversation-id", "group")
|
||||
}
|
||||
|
||||
func enrichReplyResult(data map[string]any, rt *shortcut.RuntimeContext, refSender string) {
|
||||
data["contractVersion"] = "im.message-reply.v1"
|
||||
data["conversationId"] = rt.Str("conversation-id")
|
||||
data["conversationId"] = replyConversationID(rt)
|
||||
data["referencedMessage"] = map[string]any{
|
||||
"messageId": replyMessageID(rt),
|
||||
"senderOpenDingTalkId": refSender,
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestPrimaryParamMigrationChatContentPayloadCompatibility(t *testing.T) {
|
||||
commands := []struct {
|
||||
command string
|
||||
baseArgs []string
|
||||
wantProduct string
|
||||
wantTool string
|
||||
payloadKey string
|
||||
}{
|
||||
{
|
||||
command: "+messages-send-by-bot",
|
||||
baseArgs: []string{"--robot-code", "robot", "--group", "cid", "--title", "title"},
|
||||
wantProduct: "bot",
|
||||
wantTool: "send_robot_group_message",
|
||||
payloadKey: "markdown",
|
||||
},
|
||||
{
|
||||
command: "+messages-batch-send-by-bot",
|
||||
baseArgs: []string{"--robot-code", "robot", "--users", "u1", "--title", "title"},
|
||||
wantProduct: "bot",
|
||||
wantTool: "batch_send_robot_msg_to_users",
|
||||
payloadKey: "markdown",
|
||||
},
|
||||
{
|
||||
command: "+messages-send-by-webhook",
|
||||
baseArgs: []string{"--token", "token", "--title", "title"},
|
||||
wantProduct: "bot",
|
||||
wantTool: "send_message_by_custom_robot",
|
||||
payloadKey: "text",
|
||||
},
|
||||
}
|
||||
spellings := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantValue string
|
||||
}{
|
||||
{name: "old-only", args: []string{"--text", "legacy-value"}, wantValue: "legacy-value"},
|
||||
{name: "new-only", args: []string{"--content", "legacy-value"}, wantValue: "legacy-value"},
|
||||
{
|
||||
name: "both-different-old-wins",
|
||||
args: []string{"--text", "legacy-value", "--content", "canonical-value"},
|
||||
wantValue: "legacy-value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, command := range commands {
|
||||
payloads := make(map[string]map[string]any, len(spellings))
|
||||
for _, spelling := range spellings {
|
||||
t.Run(command.command+"/"+spelling.name, func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
args := append([]string{"chat", command.command}, command.baseArgs...)
|
||||
args = append(args, spelling.args...)
|
||||
args = append(args, "--yes")
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute %v: %v", args, err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one", fake.calls)
|
||||
}
|
||||
call := fake.calls[0]
|
||||
if call.product != command.wantProduct || call.tool != command.wantTool {
|
||||
t.Fatalf("call = %s/%s, want %s/%s", call.product, call.tool, command.wantProduct, command.wantTool)
|
||||
}
|
||||
if got := call.args[command.payloadKey]; got != spelling.wantValue {
|
||||
t.Fatalf("%s = %#v, want %q; args=%#v", command.payloadKey, got, spelling.wantValue, call.args)
|
||||
}
|
||||
payloads[spelling.name] = call.args
|
||||
})
|
||||
}
|
||||
if !reflect.DeepEqual(payloads["old-only"], payloads["new-only"]) {
|
||||
t.Errorf("%s old/new payloads differ: old=%#v new=%#v", command.command, payloads["old-only"], payloads["new-only"])
|
||||
}
|
||||
if !reflect.DeepEqual(payloads["old-only"], payloads["both-different-old-wins"]) {
|
||||
t.Errorf("%s old/both payloads differ: old=%#v both=%#v", command.command, payloads["old-only"], payloads["both-different-old-wins"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationMessagesReplyPayloadCompatibility(t *testing.T) {
|
||||
spellings := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantConversation string
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "old-only",
|
||||
args: []string{"--conversation-id", "legacy-cid", "--text", "legacy-body"},
|
||||
wantConversation: "legacy-cid",
|
||||
wantContent: "legacy-body",
|
||||
},
|
||||
{
|
||||
name: "new-only",
|
||||
args: []string{"--group", "legacy-cid", "--content", "legacy-body"},
|
||||
wantConversation: "legacy-cid",
|
||||
wantContent: "legacy-body",
|
||||
},
|
||||
{
|
||||
name: "both-different-old-wins",
|
||||
args: []string{
|
||||
"--conversation-id", "legacy-cid", "--group", "canonical-cid",
|
||||
"--text", "legacy-body", "--content", "canonical-body",
|
||||
},
|
||||
wantConversation: "legacy-cid",
|
||||
wantContent: "legacy-body",
|
||||
},
|
||||
}
|
||||
|
||||
payloads := make(map[string]map[string]any, len(spellings))
|
||||
for _, spelling := range spellings {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"chat/send_personal_message": `{"result":{"openMessageId":"new-msg"}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
args := []string{
|
||||
"chat", "+messages-reply", "--message-id", "msg",
|
||||
"--ref-sender", fixtureCurrentDOpenID,
|
||||
}
|
||||
args = append(args, spelling.args...)
|
||||
args = append(args, "--yes")
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute %v: %v", args, err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one", fake.calls)
|
||||
}
|
||||
call := fake.calls[0]
|
||||
if call.product != "chat" || call.tool != "send_personal_message" {
|
||||
t.Fatalf("reply call = %#v", call)
|
||||
}
|
||||
assertReplyPrimaryPayload(t, call.args, spelling.wantConversation, spelling.wantContent)
|
||||
payloads[spelling.name] = call.args
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode reply output %q: %v", output.String(), err)
|
||||
}
|
||||
if got := payload["conversationId"]; got != spelling.wantConversation {
|
||||
t.Fatalf("result conversationId = %#v, want %q; payload=%#v", got, spelling.wantConversation, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !reflect.DeepEqual(payloads["old-only"], payloads["new-only"]) {
|
||||
t.Errorf("reply old/new payloads differ: old=%#v new=%#v", payloads["old-only"], payloads["new-only"])
|
||||
}
|
||||
if !reflect.DeepEqual(payloads["old-only"], payloads["both-different-old-wins"]) {
|
||||
t.Errorf("reply old/both payloads differ: old=%#v both=%#v", payloads["old-only"], payloads["both-different-old-wins"])
|
||||
}
|
||||
|
||||
t.Run("dry-run-both-different-old-wins", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-reply", "--message-id", "msg", "--ref-sender", fixtureCurrentDOpenID,
|
||||
"--conversation-id", "legacy-cid", "--group", "canonical-cid",
|
||||
"--text", "legacy-body", "--content", "canonical-body",
|
||||
"--dry-run", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("dry-run reached transport: %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output %q: %v", output.String(), err)
|
||||
}
|
||||
if got := payload["conversationId"]; got != "legacy-cid" {
|
||||
t.Fatalf("dry-run conversationId = %#v, want legacy-cid", got)
|
||||
}
|
||||
arguments, _ := payload["arguments"].(map[string]any)
|
||||
assertReplyPrimaryPayload(t, arguments, "legacy-cid", "legacy-body")
|
||||
})
|
||||
}
|
||||
|
||||
func assertReplyPrimaryPayload(t *testing.T, args map[string]any, wantConversation, wantContent string) {
|
||||
t.Helper()
|
||||
if got := args["openConversationId"]; got != wantConversation {
|
||||
t.Fatalf("openConversationId = %#v, want %q; args=%#v", got, wantConversation, args)
|
||||
}
|
||||
contentText, _ := args["content"].(string)
|
||||
var content map[string]string
|
||||
if err := json.Unmarshal([]byte(contentText), &content); err != nil {
|
||||
t.Fatalf("decode reply content %q: %v", contentText, err)
|
||||
}
|
||||
if got := content["content"]; got != wantContent {
|
||||
t.Fatalf("reply content = %q, want %q; payload=%#v", got, wantContent, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationChatSurfaceAndContract(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
params []chatPrimaryParamExpectation
|
||||
}{
|
||||
{
|
||||
command: "+messages-send-by-bot",
|
||||
params: []chatPrimaryParamExpectation{{name: "content", legacy: "text", property: "text"}},
|
||||
},
|
||||
{
|
||||
command: "+messages-batch-send-by-bot",
|
||||
params: []chatPrimaryParamExpectation{{name: "content", legacy: "text", property: "text"}},
|
||||
},
|
||||
{
|
||||
command: "+messages-send-by-webhook",
|
||||
params: []chatPrimaryParamExpectation{{name: "content", legacy: "text", property: "text"}},
|
||||
},
|
||||
{
|
||||
command: "+messages-reply",
|
||||
params: []chatPrimaryParamExpectation{
|
||||
{name: "group", legacy: "conversation-id", property: "conversationId"},
|
||||
{name: "content", legacy: "text", property: "text"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.command, func(t *testing.T) {
|
||||
root := newPlatformCoverageRoot()
|
||||
cmd, _, err := root.Find([]string{"chat", test.command})
|
||||
if err != nil || cmd == nil {
|
||||
t.Fatalf("find command: %v", err)
|
||||
}
|
||||
for _, param := range test.params {
|
||||
canonical := cmd.Flags().Lookup(param.name)
|
||||
if canonical == nil || canonical.Hidden {
|
||||
t.Fatalf("canonical --%s = %#v, want visible", param.name, canonical)
|
||||
}
|
||||
legacy := cmd.Flags().Lookup(param.legacy)
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("legacy --%s = %#v, want hidden", param.legacy, legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != param.name {
|
||||
t.Fatalf("--%s alias_of = %#v, want %s", param.legacy, got, param.name)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("--%s alias origin = %#v", param.legacy, got)
|
||||
}
|
||||
}
|
||||
|
||||
var help bytes.Buffer
|
||||
cmd.SetOut(&help)
|
||||
if err := cmd.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, param := range test.params {
|
||||
if !strings.Contains(help.String(), "--"+param.name) || strings.Contains(help.String(), "--"+param.legacy) {
|
||||
t.Fatalf("help does not expose only --%s (legacy --%s):\n%s", param.name, param.legacy, help.String())
|
||||
}
|
||||
}
|
||||
|
||||
spec := chatRegisteredShortcut(t, test.command)
|
||||
if len(spec.Contract.Parameters) != len(test.params) {
|
||||
t.Fatalf("ParamDecls = %#v, want %d", spec.Contract.Parameters, len(test.params))
|
||||
}
|
||||
for i, want := range test.params {
|
||||
param := spec.Contract.Parameters[i]
|
||||
if param.Name != want.name || param.Property != want.property ||
|
||||
param.Required == nil || !*param.Required || param.InterfaceType != "" {
|
||||
t.Fatalf("ParamDecl[%d] = %#v, want %#v", i, param, want)
|
||||
}
|
||||
}
|
||||
for _, example := range append(append([]string(nil), spec.Tips...), spec.Contract.Selection.Examples...) {
|
||||
for _, param := range test.params {
|
||||
if strings.Contains(example, "--"+param.legacy) || !strings.Contains(example, "--"+param.name) {
|
||||
t.Fatalf("example does not recommend only --%s: %q", param.name, example)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type chatPrimaryParamExpectation struct {
|
||||
name string
|
||||
legacy string
|
||||
property string
|
||||
}
|
||||
|
||||
func chatRegisteredShortcut(t *testing.T, command string) shortcut.Shortcut {
|
||||
t.Helper()
|
||||
for _, spec := range shortcut.All() {
|
||||
if spec.Service == "chat" && spec.Command == command {
|
||||
return spec
|
||||
}
|
||||
}
|
||||
t.Fatalf("registered shortcut chat %s not found", command)
|
||||
return shortcut.Shortcut{}
|
||||
}
|
||||
@@ -87,18 +87,46 @@ func withReviewedChatShortcutContracts(values ...shortcut.Shortcut) []shortcut.S
|
||||
out := make([]shortcut.Shortcut, len(values))
|
||||
for i, value := range values {
|
||||
out[i] = value
|
||||
if !value.Contract.Empty() {
|
||||
continue
|
||||
if value.Contract.Empty() {
|
||||
if _, reviewed := reviewedChatShortcutContractCommands[value.Command]; reviewed {
|
||||
out[i].Safety = reviewedChatShortcutSafety(value.Risk)
|
||||
out[i].Contract = reviewedChatShortcutContract(value)
|
||||
}
|
||||
}
|
||||
if _, reviewed := reviewedChatShortcutContractCommands[value.Command]; !reviewed {
|
||||
continue
|
||||
}
|
||||
out[i].Safety = reviewedChatShortcutSafety(value.Risk)
|
||||
out[i].Contract = reviewedChatShortcutContract(value)
|
||||
out[i].Contract.Parameters = append(
|
||||
out[i].Contract.Parameters,
|
||||
reviewedChatPrimaryParamDecls(value.Command)...,
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func reviewedChatPrimaryParamDecls(command string) []contract.ParamDecl {
|
||||
switch command {
|
||||
case "+messages-send-by-bot", "+messages-batch-send-by-bot", "+messages-send-by-webhook":
|
||||
return []contract.ParamDecl{renamedRequiredChatParam("content", "text")}
|
||||
case "+messages-reply":
|
||||
return []contract.ParamDecl{
|
||||
renamedRequiredChatParam("group", "conversationId"),
|
||||
renamedRequiredChatParam("content", "text"),
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// renamedRequiredChatParam preserves the pre-rename Schema property and
|
||||
// requiredness. InterfaceType stays unset because these Shortcut parameters
|
||||
// historically published only their Cobra string type.
|
||||
func renamedRequiredChatParam(name, property string) contract.ParamDecl {
|
||||
required := true
|
||||
return contract.ParamDecl{
|
||||
Name: name,
|
||||
Property: property,
|
||||
Required: &required,
|
||||
}
|
||||
}
|
||||
|
||||
func reviewedChatShortcutSafety(risk shortcut.Risk) contract.SafetySpec {
|
||||
switch risk {
|
||||
case shortcut.RiskWrite:
|
||||
|
||||
@@ -124,18 +124,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"calendar\u0000+book-search": {},
|
||||
"calendar\u0000+cancel-event": {},
|
||||
"calendar\u0000+conflicts": {},
|
||||
"calendar\u0000+create": {},
|
||||
"calendar\u0000+free": {},
|
||||
"calendar\u0000+free-slots": {},
|
||||
"calendar\u0000+freebusy": {},
|
||||
"calendar\u0000+get": {},
|
||||
"calendar\u0000+invite": {},
|
||||
"calendar\u0000+my-free": {},
|
||||
"calendar\u0000+next-event": {},
|
||||
"calendar\u0000+reschedule": {},
|
||||
"calendar\u0000+room-find": {},
|
||||
"calendar\u0000+room-groups": {},
|
||||
"calendar\u0000+room-search": {},
|
||||
"calendar\u0000+rsvp": {},
|
||||
"calendar\u0000+search-event": {},
|
||||
"calendar\u0000+suggest-time": {},
|
||||
"calendar\u0000+suggestion": {},
|
||||
"calendar\u0000+today": {},
|
||||
"calendar\u0000+tomorrow": {},
|
||||
"calendar\u0000+update": {},
|
||||
"calendar\u0000+week": {},
|
||||
"chat\u0000+at-me": {},
|
||||
"chat\u0000+bot-find": {},
|
||||
@@ -395,15 +402,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"sheet\u0000+read": {},
|
||||
"todo\u0000+assign": {},
|
||||
"todo\u0000+assign-multi": {},
|
||||
"todo\u0000+comment": {},
|
||||
"todo\u0000+complete": {},
|
||||
"todo\u0000+create": {},
|
||||
"todo\u0000+created-todos": {},
|
||||
"todo\u0000+due-today": {},
|
||||
"todo\u0000+get": {},
|
||||
"todo\u0000+get-my-tasks": {},
|
||||
"todo\u0000+get-related-tasks": {},
|
||||
"todo\u0000+list-attachment": {},
|
||||
"todo\u0000+list-comment": {},
|
||||
"todo\u0000+list-sub": {},
|
||||
"todo\u0000+overdue": {},
|
||||
"todo\u0000+related-tasks": {},
|
||||
"todo\u0000+remind": {},
|
||||
"todo\u0000+reminder": {},
|
||||
"todo\u0000+reopen": {},
|
||||
"todo\u0000+search": {},
|
||||
"todo\u0000+todo-done": {},
|
||||
"todo\u0000+update": {},
|
||||
"wiki\u0000+delete-space": {},
|
||||
"wiki\u0000+feed-list": {},
|
||||
"wiki\u0000+member-add": {},
|
||||
|
||||
@@ -28,6 +28,12 @@ var driveSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_wiki.json
|
||||
var wikiSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_calendar.json
|
||||
var calendarSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_todo.json
|
||||
var todoSemanticCatalogJSON []byte
|
||||
|
||||
type semanticCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
@@ -52,6 +58,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
minutesSemanticCatalogJSON,
|
||||
driveSemanticCatalogJSON,
|
||||
wikiSemanticCatalogJSON,
|
||||
calendarSemanticCatalogJSON,
|
||||
todoSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "calendar",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+agenda": {"disposition":"semantic_adapter","semantic_delta":"严格验证 result.events 与分页证据,稳定投影 id 为 eventId;显式空数组才是空日程。","risk":"read","public":true,"reviewed":true},
|
||||
"+get": {"disposition":"schema_leaf","semantic_delta":"严格读取日程对象并把稳定 id 规范化为 eventId,拒绝空响应和对象漂移。","risk":"read","public":true,"reviewed":true},
|
||||
"+create": {"disposition":"primary_smart","semantic_delta":"创建响应必须给出 eventId/id,且标题和起止时间通过详情读回后才成功。","risk":"write","public":true,"reviewed":true},
|
||||
"+update": {"disposition":"primary_smart","semantic_delta":"组合字段与参会人分阶段更新,明确非事务步骤并以详情和参会人列表读回验证。","risk":"write","public":true,"reviewed":true},
|
||||
"+rsvp": {"disposition":"semantic_adapter","semantic_delta":"把直白 RSVP 动作映射为 responseStatus,拒绝空 ack,并在写后读取日程终态。","risk":"write","public":true,"reviewed":true},
|
||||
"+search-event": {"disposition":"semantic_adapter","semantic_delta":"在服务端日程当前页按标题、描述和地点过滤,同时保留 hasMore/nextCursor,绝不把单页零命中当全局未找到。","risk":"read","public":true,"reviewed":true},
|
||||
"+suggestion": {"disposition":"schema_leaf","semantic_delta":"直接按 userId 查询建议时段,严格区分显式空 recommendEventTimes 与缺失或畸形响应。","risk":"read","public":true,"reviewed":true},
|
||||
|
||||
"+attendee-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证参会人数组并投影显示名、响应状态和 self;真实后端不返回稳定参会人 userId,因此不伪造身份字段。","risk":"read","public":true,"reviewed":true},
|
||||
"+room-search": {"disposition":"semantic_adapter","semantic_delta":"按名称查询会议室但不声称检查时间可用性;缺失会议室数组直接失败。","risk":"read","public":true,"reviewed":true},
|
||||
"+room-find": {"disposition":"semantic_adapter","semantic_delta":"按单个未来时间段严格查询可用会议室,公开真实 page/pageSize/hasMore 证据和后端筛选边界。","risk":"read","public":true,"reviewed":true},
|
||||
"+room-groups": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.groupList,避免会议室分组被响应 wrapper 漂移静默清空。","risk":"read","public":true,"reviewed":true},
|
||||
"+freebusy": {"disposition":"semantic_adapter","semantic_delta":"统一按用户或会议室 ID 查询指定范围闲忙,并声明至少一类目标约束。","risk":"read","public":true,"reviewed":true},
|
||||
"+book-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证日历本数组并稳定投影 calendarId,缺失数组不再误报为空。","risk":"read","public":true,"reviewed":true},
|
||||
"+book-search": {"disposition":"semantic_adapter","semantic_delta":"按日历本名称搜索并严格区分零命中与协议错误。","risk":"read","public":true,"reviewed":true},
|
||||
|
||||
"+book": {"disposition":"primary_smart","semantic_delta":"按参会人姓名解析后创建日程,并在多步骤路径提供失败回滚与详情读回。","risk":"write","public":true,"reviewed":true},
|
||||
"+cancel-event": {"disposition":"primary_smart","semantic_delta":"删除前读取并展示目标,经高风险确认后取消指定日程。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+conflicts": {"disposition":"primary_smart","semantic_delta":"在指定范围分析重叠日程并输出冲突对,避免人工逐项比对。","risk":"read","public":true,"reviewed":true},
|
||||
"+free": {"disposition":"primary_smart","semantic_delta":"按姓名解析参与者后查询闲忙,省去手工 userId 解析。","risk":"read","public":true,"reviewed":true},
|
||||
"+free-slots": {"disposition":"primary_smart","semantic_delta":"从已有日程计算工作时段内空档,并显式输出满足时长的候选区间。","risk":"read","public":true,"reviewed":true},
|
||||
"+invite": {"disposition":"primary_smart","semantic_delta":"按姓名唯一解析用户后邀请到已有日程。","risk":"write","public":true,"reviewed":true},
|
||||
"+my-free": {"disposition":"primary_smart","semantic_delta":"以当前用户身份查询一个时间段是否空闲。","risk":"read","public":true,"reviewed":true},
|
||||
"+next-event": {"disposition":"primary_smart","semantic_delta":"在未来时间窗内定位最近的下一场日程。","risk":"read","public":true,"reviewed":true},
|
||||
"+reschedule": {"disposition":"primary_smart","semantic_delta":"修改前读取目标日程,再更新完整起止时间。","risk":"write","public":true,"reviewed":true},
|
||||
"+suggest-time": {"disposition":"primary_smart","semantic_delta":"按姓名逐个唯一解析参与者,再查询大家共同可用的建议时间。","risk":"read","public":true,"reviewed":true},
|
||||
"+today": {"disposition":"primary_smart","semantic_delta":"查询并整理今天的日程视图。","risk":"read","public":true,"reviewed":true},
|
||||
"+tomorrow": {"disposition":"primary_smart","semantic_delta":"查询并整理明天的日程视图。","risk":"read","public":true,"reviewed":true},
|
||||
"+week": {"disposition":"primary_smart","semantic_delta":"查询并按日期整理本周日程。","risk":"read","public":true,"reviewed":true},
|
||||
|
||||
"+find-room": {"disposition":"alias_internal","semantic_delta":"兼容旧的双时间参数会议室查询入口;新调用统一使用 +room-find。","risk":"read","availability":"deprecated","primary":"+room-find","public":false,"reviewed":true},
|
||||
"+respond-event": {"disposition":"alias_internal","semantic_delta":"兼容旧的 accept/decline/tentative 响应入口;新调用统一使用 +rsvp。","risk":"write","availability":"deprecated","primary":"+rsvp","public":false,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "todo",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+get-my-tasks": {"disposition":"semantic_adapter","semantic_delta":"严格识别 todoCards/hasMore/page/size;显式空数组才是空结果,并支持有界全量分页。","risk":"read","public":true,"reviewed":true},
|
||||
"+list-sub": {"disposition":"semantic_adapter","semantic_delta":"严格校验子待办容器、对象元素和稳定 taskId。","risk":"read","public":true,"reviewed":true},
|
||||
"+get": {"disposition":"semantic_adapter","semantic_delta":"严格绑定 todoDetailModel.taskId,缺失或错绑均失败。","risk":"read","public":true,"reviewed":true},
|
||||
"+list-attachment": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空附件与缺失容器,并要求每项稳定附件 ID。","risk":"read","public":true,"reviewed":true},
|
||||
"+list-comment": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空评论与缺失容器,并要求每项稳定 commentId。","risk":"read","public":true,"reviewed":true},
|
||||
"+create": {"disposition":"primary_smart","semantic_delta":"创建后要求稳定 taskId,并读取 todoDetailModel 验证标题。","risk":"write","public":true,"reviewed":true},
|
||||
"+update": {"disposition":"primary_smart","semantic_delta":"更新指定字段后读取详情逐字段核验。","risk":"write","public":true,"reviewed":true},
|
||||
"+complete": {"disposition":"primary_smart","semantic_delta":"先读当前状态避免重复写,必要时完成后再读回核验。","risk":"write","public":true,"reviewed":true},
|
||||
"+reopen": {"disposition":"primary_smart","semantic_delta":"先读当前状态避免重复写,必要时重开后再读回核验。","risk":"write","public":true,"reviewed":true},
|
||||
"+search": {"disposition":"primary_smart","semantic_delta":"遍历全部 hasMore 分页并按标题匹配;达到页上限仍未耗尽时失败。","risk":"read","public":true,"reviewed":true},
|
||||
"+comment": {"disposition":"primary_smart","semantic_delta":"新增评论必须取得稳定 commentId,并分页读回验证内容。","risk":"write","public":true,"reviewed":true},
|
||||
"+upload-attachment": {"disposition":"primary_smart","semantic_delta":"路由到现有原子上传事务;Shortcut 层不复制凭证和带头 OSS PUT 实现。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+reminder": {"disposition":"semantic_adapter","semantic_delta":"严格验证提醒参数和 success=true 终端回执;上游不能读回规则,固定 verified=false。","risk":"write","public":true,"reviewed":true},
|
||||
"+get-related-tasks": {"disposition":"primary_smart","semantic_delta":"遍历与我相关三种角色的全部分页并按 taskId 去重。","risk":"read","public":true,"reviewed":true},
|
||||
"+related-tasks": {"disposition":"alias_internal","semantic_delta":"保留旧命令拼写,新的 Agent 路由统一使用 +get-related-tasks。","risk":"read","primary":"+get-related-tasks","public":true,"reviewed":true},
|
||||
"+due-today": {"disposition":"primary_smart","semantic_delta":"按本地日历日构造服务端截止窗口并遍历全部分页。","risk":"read","public":true,"reviewed":true},
|
||||
"+assign": {"disposition":"primary_smart","semantic_delta":"保留历史按姓名解析后创建并指派的新任务语义,避免同名破坏性变更。","risk":"write","public":true,"reviewed":true},
|
||||
"+assign-multi": {"disposition":"primary_smart","semantic_delta":"全部姓名唯一解析后才创建一次,并以单一统一结果输出稳定 taskId。","risk":"write","public":true,"reviewed":true},
|
||||
"+created-todos": {"disposition":"primary_smart","semantic_delta":"遍历创建者角色的全部分页后投影。","risk":"read","public":true,"reviewed":true},
|
||||
"+overdue": {"disposition":"primary_smart","semantic_delta":"遍历执行者角色的全部分页并按当前时间筛选逾期项。","risk":"read","public":true,"reviewed":true},
|
||||
"+remind": {"disposition":"primary_smart","semantic_delta":"解析当前用户后创建给自己的待办;该命令不是独立提醒规则。","risk":"write","public":true,"reviewed":true},
|
||||
"+todo-done": {"disposition":"primary_smart","semantic_delta":"跨全部分页按标题唯一定位后完成任务。","risk":"write","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,13 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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"
|
||||
todoshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
|
||||
)
|
||||
|
||||
// Assign: create a todo AND assign it to a person by NAME, in one command.
|
||||
@@ -27,10 +31,11 @@ import (
|
||||
//
|
||||
// dws todo +assign --to 张三 --task "整理周报"
|
||||
var Assign = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+assign",
|
||||
Product: "todo",
|
||||
Description: "按姓名给某人创建并指派一条待办(自动解析 userId)",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+assign",
|
||||
Product: "todo",
|
||||
Description: "按姓名给某人创建并指派一条待办(自动解析 userId)",
|
||||
Intent: "当你想把一件事指派给某位同事、但只知道对方姓名不想先查 userId 时使用;" +
|
||||
"内部先按姓名解析出唯一 userId,再创建待办并把 TA 设为执行人。会真实创建待办。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
@@ -47,6 +52,7 @@ var Assign = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +assign",
|
||||
},
|
||||
Description: "按姓名给某人创建并指派一条待办(自动解析 userId)",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的单人指派待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"executorId":{"type":"string","description":"解析出的执行人 userId"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","executorId","verified"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -88,9 +94,21 @@ var Assign = shortcut.Shortcut{
|
||||
}
|
||||
vo["dueTime"] = ms
|
||||
}
|
||||
return rt.CallMCP("create_personal_todo", map[string]any{
|
||||
params := map[string]any{
|
||||
"PersonalTodoCreateVO": vo,
|
||||
})
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": rt.Str("task")})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", rt.Str("task"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "subject": rt.Str("task"), "executorId": user.userID, "verified": true})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -21,7 +22,9 @@ import (
|
||||
|
||||
"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"
|
||||
todoshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
|
||||
)
|
||||
|
||||
// AssignMulti: create ONE personal todo and assign it to SEVERAL people at once,
|
||||
@@ -41,10 +44,11 @@ import (
|
||||
//
|
||||
// dws todo +assign-multi --to "张三,李四,王五" --task "周五前提交排期"
|
||||
var AssignMulti = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+assign-multi",
|
||||
Product: "todo",
|
||||
Description: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+assign-multi",
|
||||
Product: "todo",
|
||||
Description: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
|
||||
Intent: "当你想把同一条待办同时指派给好几个同事、但手上只有他们的姓名而不是 userId 时使用;" +
|
||||
"内部会把 --to 里的每个姓名逐个解析成唯一 userId,只要有任何一个姓名查不到或者重名有歧义," +
|
||||
"就把这些问题一次性汇总报错、并且完全不创建待办(不会建出只指派了一半人的残缺待办)。" +
|
||||
@@ -63,6 +67,7 @@ var AssignMulti = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +assign-multi",
|
||||
},
|
||||
Description: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的多人指派待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"executors":{"type":"array","description":"已解析执行人摘要","items":{"type":"string"}},"count":{"type":"integer","description":"执行人数"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","executors","count","verified"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -126,19 +131,30 @@ var AssignMulti = shortcut.Shortcut{
|
||||
|
||||
// Create the todo once, assigning all resolved executors. Params mirror the
|
||||
// todo helper's `task create` call site verbatim.
|
||||
if err := rt.CallMCP("create_personal_todo", map[string]any{
|
||||
params := map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": task,
|
||||
"executorIds": executorIDs,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": task, "count": len(executorIDs)})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rt.Output(map[string]any{
|
||||
"taskId": taskID,
|
||||
"subject": task,
|
||||
"executors": resolved,
|
||||
"count": len(executorIDs),
|
||||
"verified": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -85,112 +85,130 @@ var Book = shortcut.Shortcut{
|
||||
`dws calendar +book --title "Q1 复盘会" --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --with 张三,李四`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
start := rt.Str("start")
|
||||
end := rt.Str("end")
|
||||
if err := calendarSmartValidateRange(start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
// create_calendar_event params copied verbatim from the helper's create
|
||||
// call site: summary + startDateTime/endDateTime (ISO strings, not millis).
|
||||
createArgs := map[string]any{
|
||||
"summary": rt.Str("title"),
|
||||
"startDateTime": rt.Str("start"),
|
||||
"endDateTime": rt.Str("end"),
|
||||
}
|
||||
|
||||
// Fast path: no participants → create and print in one step.
|
||||
if !rt.Changed("with") || strings.TrimSpace(rt.Str("with")) == "" {
|
||||
return rt.CallMCP("create_calendar_event", createArgs)
|
||||
"startDateTime": start,
|
||||
"endDateTime": end,
|
||||
}
|
||||
|
||||
// Step 1 — resolve every participant name to a unique userId BEFORE
|
||||
// creating the event, so an unknown/ambiguous name fails cheaply without
|
||||
// leaving a dangling event behind.
|
||||
var userIDs []string
|
||||
for _, name := range strings.Split(rt.Str("with"), ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
var userNames []string
|
||||
withPeople := rt.Changed("with") && strings.TrimSpace(rt.Str("with")) != ""
|
||||
if withPeople {
|
||||
for _, name := range strings.Split(rt.Str("with"), ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
user, err := resolveUser(rt, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userIDs = append(userIDs, user.userID)
|
||||
userNames = append(userNames, user.name)
|
||||
}
|
||||
user, err := resolveUser(rt, name)
|
||||
if err != nil {
|
||||
return err
|
||||
if len(userIDs) == 0 {
|
||||
return apperrors.NewValidation("--with 需要至少一个有效的参会人姓名")
|
||||
}
|
||||
userIDs = append(userIDs, user.userID)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return apperrors.NewValidation("--with 需要至少一个有效的参会人姓名")
|
||||
}
|
||||
|
||||
// Under --dry-run we resolved names (reads) to validate them, but must not
|
||||
// create the event or send invites. Preview what would happen and return.
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"dryRun": true,
|
||||
"wouldCreate": createArgs,
|
||||
"wouldInvite": userIDs,
|
||||
"success": true,
|
||||
"dryRun": true,
|
||||
"executed": false,
|
||||
"wouldCreate": createArgs,
|
||||
"inviteeCount": len(userIDs),
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2 — create the event and pull the eventId from the response.
|
||||
created, err := rt.CallMCPWriteData("calendar", "create_calendar_event", createArgs)
|
||||
// Step 2 — create the event and require an explicit terminal receipt plus
|
||||
// a stable id. Empty/unknown write acknowledgements are never success.
|
||||
created, err := rt.CallMCPWriteDataStrict("calendar", "create_calendar_event", createArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventID := shortcutExtractEventID(created)
|
||||
if err := calendarSmartWriteReceipt(created, "calendar/create_calendar_event"); err != nil {
|
||||
return err
|
||||
}
|
||||
eventID := calendarSmartEventID(created)
|
||||
if eventID == "" {
|
||||
return apperrors.NewValidation("日程创建成功但无法从返回结果中解析 eventId,已跳过邀请参会人")
|
||||
return calendarSmartError("calendar/create_calendar_event", "missing_event_id", "创建回执缺少稳定日程 id,远端效果未知")
|
||||
}
|
||||
|
||||
// Step 3 — add all participants in one batch. attendeesToAdd copied
|
||||
// verbatim from the helper's `attendee add` call site.
|
||||
if _, err := rt.CallMCPWriteData("calendar", "add_calendar_participant", map[string]any{
|
||||
"eventId": eventID,
|
||||
"attendeesToAdd": userIDs,
|
||||
}); err != nil {
|
||||
// Rollback: delete the just-created event so we never leave a
|
||||
// half-built event without its intended attendees.
|
||||
_, delErr := rt.CallMCPWriteData("calendar", "delete_calendar_event", map[string]any{
|
||||
"eventId": eventID,
|
||||
if len(userIDs) > 0 {
|
||||
added, addErr := rt.CallMCPWriteDataStrict("calendar", "add_calendar_participant", map[string]any{
|
||||
"eventId": eventID,
|
||||
"attendeesToAdd": userIDs,
|
||||
})
|
||||
if delErr != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"添加参会人失败:%v;且回滚删除日程 %s 也失败:%v(请手动删除该日程)",
|
||||
err, eventID, delErr))
|
||||
if addErr == nil {
|
||||
addErr = calendarSmartWriteReceipt(added, "calendar/add_calendar_participant")
|
||||
}
|
||||
if addErr != nil {
|
||||
// Rollback itself is not enough: verify the newly-created event is
|
||||
// absent before saying rollback succeeded.
|
||||
if rollbackErr := calendarSmartDeleteAndVerify(rt, eventID); rollbackErr != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"添加参会人失败:%v;回滚删除或删除后验证失败:%v,请人工核查日程状态",
|
||||
addErr, rollbackErr))
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf("添加参会人失败:%v;新建日程已回滚并验证不存在", addErr))
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"添加参会人失败:%v;已回滚删除日程 %s", err, eventID))
|
||||
}
|
||||
|
||||
// Step 4 — success: print the final event detail (eventId + attendees).
|
||||
return rt.CallMCP("get_calendar_detail", map[string]any{"eventId": eventID})
|
||||
// Step 4 — prove the final state before returning one composed result.
|
||||
readback, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{"eventId": eventID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, err := calendarSmartRequireEvent(readback, "calendar/get_calendar_detail", eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := calendarSmartVerifyCreatedEvent(event, eventID, rt.Str("title"), start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
participants, err := rt.CallMCPData("calendar", "get_calendar_participants", map[string]any{"eventId": eventID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present, err := calendarSmartAttendees(participants)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentUserID, err := calendarSmartCurrentUserID(rt, present)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := calendarSmartVerifyAttendees(present, userIDs, userNames, currentUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"success": true,
|
||||
"eventId": eventID,
|
||||
"verified": true,
|
||||
"event": event,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// shortcutExtractEventID digs the eventId out of a create_calendar_event
|
||||
// response, tolerating the common shapes ({eventId}, {id}, {result:{...}},
|
||||
// {event:{...}}) since the exact envelope is not guaranteed.
|
||||
func shortcutExtractEventID(data map[string]any) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
if id := shortcutPickID(data); id != "" {
|
||||
return id
|
||||
}
|
||||
for _, key := range []string{"result", "event", "data"} {
|
||||
if nested, ok := data[key].(map[string]any); ok {
|
||||
if id := shortcutPickID(nested); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shortcutPickID(m map[string]any) string {
|
||||
for _, k := range []string{"eventId", "id"} {
|
||||
if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&Book, "已创建并通过精确读回验证的日程")
|
||||
shortcut.Register(Book)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import (
|
||||
// without aborting delivery to the others. Replaces manually running
|
||||
// `chat +dm` once per recipient.
|
||||
//
|
||||
// dws chat +broadcast --to "张三,李四,王五" --text "今晚 8 点上线,请留意群公告"
|
||||
// dws chat +broadcast --to "张三,李四,王五" --content "今晚 8 点上线,请留意群公告"
|
||||
var Broadcast = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+broadcast",
|
||||
@@ -66,17 +66,18 @@ var Broadcast = shortcut.Shortcut{
|
||||
AgentSummary: "按姓名逐一给多个人群发同一条单聊消息(自动解析 userId、逐个发送)",
|
||||
UseWhen: []string{"当你想把同一条通知一次性单聊发给多位同事、但只知道他们的姓名不想逐个查 userId 时使用;内部把姓名列表逐个解析成唯一用户后,用 openDingTalkId 对每个人单独发一条单聊消息,并汇总成功/失败人数。某个姓名匹配不到人或匹配到多人时,会跳过该人并在结尾报出,不影响其他人收到消息。会真实发出多条消息。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +broadcast --to \"张三,李四,王五\" --text \"今晚 8 点上线,请留意\""},
|
||||
Examples: []string{"dws chat +broadcast --to \"张三,李四,王五\" --content \"今晚 8 点上线,请留意\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{renamedRequiredParam("content", "text")},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件人姓名/花名,逗号分隔的多个人", Required: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown),所有人收到同一条", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown),所有人收到同一条", Required: true, Aliases: []string{"text"}},
|
||||
shortcut.AIMessageTagFlag(),
|
||||
},
|
||||
Tips: []string{`dws chat +broadcast --to "张三,李四,王五" --text "今晚 8 点上线,请留意"`},
|
||||
Tips: []string{`dws chat +broadcast --to "张三,李四,王五" --content "今晚 8 点上线,请留意"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
text := rt.Str("text")
|
||||
text := rt.StrFirst("text", "content")
|
||||
names := rt.StrSlice("to")
|
||||
if len(names) == 0 {
|
||||
return apperrors.NewValidation("--to 至少要包含一个姓名")
|
||||
|
||||
@@ -14,10 +14,19 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const calendarSmartMaxPages = 20
|
||||
|
||||
// calendarDayRange returns the [00:00, next-day 00:00) window for "today plus
|
||||
// offsetDays" in the local timezone. Shared by the day-scoped calendar
|
||||
// shortcuts (+today offset 0, +tomorrow offset 1, +conflicts --in-days).
|
||||
@@ -28,16 +37,471 @@ func calendarDayRange(offsetDays int) (time.Time, time.Time) {
|
||||
}
|
||||
|
||||
// calendarProjectEvents lists and projects the events in a list_calendar_events
|
||||
// response into clean {title,start,end,location,eventId} items, skipping events
|
||||
// that carry no id. Shared by +today / +tomorrow / +week so the projection lives
|
||||
// in exactly one place (reuses shortcutNextEventProject).
|
||||
func calendarProjectEvents(data map[string]any) []map[string]any {
|
||||
// response into clean {title,start,end,location,eventId} items. The strict page
|
||||
// parser has already required stable ids and valid time boundaries.
|
||||
func calendarProjectEvents(raw []map[string]any) []map[string]any {
|
||||
events := make([]map[string]any, 0)
|
||||
for _, e := range shortcutNextEventList(data) {
|
||||
if id, _ := e["id"].(string); strings.TrimSpace(id) == "" {
|
||||
continue
|
||||
}
|
||||
for _, e := range raw {
|
||||
events = append(events, shortcutNextEventProject(e))
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func calendarSmartResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"additionalProperties":true}`,
|
||||
description,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func finalizeCalendarSmart(item *shortcut.Shortcut, description string) {
|
||||
item.OutputRollout = output.RolloutUnifiedActive
|
||||
item.Contract.Result = calendarSmartResult(description)
|
||||
}
|
||||
|
||||
func calendarSmartError(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 calendarSmartRequireSuccess(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, calendarSmartError(operation, "empty_tool_response", "服务返回空响应,无法证明成功或合法空结果")
|
||||
}
|
||||
raw, present := data["success"]
|
||||
if !present {
|
||||
return nil, calendarSmartError(operation, "missing_success", "响应缺少 success 终态字段")
|
||||
}
|
||||
success, ok := raw.(bool)
|
||||
if !ok {
|
||||
return nil, calendarSmartError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
||||
}
|
||||
if !success {
|
||||
message := calendarSmartFirstString(data, "errorMsg", "errorMessage", "message", "error")
|
||||
if message == "" {
|
||||
message = "服务明确返回 success=false"
|
||||
}
|
||||
return nil, calendarSmartError(operation, "remote_failure", message)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func calendarSmartRequireEvent(data map[string]any, operation, expectedID string) (map[string]any, error) {
|
||||
data, err := calendarSmartRequireSuccess(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
event, ok := data["result"].(map[string]any)
|
||||
if !ok || len(event) == 0 {
|
||||
return nil, calendarSmartError(operation, "missing_event", "响应 result 不是非空日程对象")
|
||||
}
|
||||
id := calendarSmartFirstString(event, "id", "eventId", "event_id")
|
||||
if id == "" {
|
||||
return nil, calendarSmartError(operation, "missing_event_id", "日程对象缺少稳定 id")
|
||||
}
|
||||
if expectedID != "" && id != expectedID {
|
||||
return nil, calendarSmartError(operation, "event_id_mismatch", "读回日程 id 与请求 eventId 不一致")
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func calendarSmartFirstString(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 calendarSmartEventID(data map[string]any) string {
|
||||
if value := calendarSmartFirstString(data, "id", "eventId", "event_id"); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, key := range []string{"result", "event", "data"} {
|
||||
if nested, ok := data[key].(map[string]any); ok {
|
||||
if value := calendarSmartFirstString(nested, "id", "eventId", "event_id"); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func calendarSmartListAll(rt *shortcut.RuntimeContext, params map[string]any) ([]map[string]any, error) {
|
||||
all := make([]map[string]any, 0)
|
||||
seenIDs := map[string]bool{}
|
||||
cursor := ""
|
||||
for page := 1; page <= calendarSmartMaxPages; page++ {
|
||||
request := make(map[string]any, len(params)+2)
|
||||
for key, value := range params {
|
||||
request[key] = value
|
||||
}
|
||||
request["limit"] = 100
|
||||
if cursor != "" {
|
||||
request["cursor"] = cursor
|
||||
}
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, hasMore, next, err := calendarSmartEventPage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, event := range items {
|
||||
id := calendarSmartFirstString(event, "id", "eventId", "event_id")
|
||||
if !seenIDs[id] {
|
||||
seenIDs[id] = true
|
||||
all = append(all, event)
|
||||
}
|
||||
}
|
||||
if !hasMore {
|
||||
return all, nil
|
||||
}
|
||||
if next == cursor {
|
||||
return nil, calendarSmartError("calendar/list_calendar_events", "stalled_cursor", "服务端 nextCursor 未前进,不能证明列表完整")
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
return nil, calendarSmartError("calendar/list_calendar_events", "page_limit", fmt.Sprintf("自动翻页达到 %d 页上限,拒绝基于不完整事件集给出结论", calendarSmartMaxPages))
|
||||
}
|
||||
|
||||
func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string, error) {
|
||||
data, err := calendarSmartRequireSuccess(data, "calendar/list_calendar_events")
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_result", "响应 result 不是对象")
|
||||
}
|
||||
rawEvents, present := result["events"]
|
||||
if !present {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_events", "响应缺少显式 events 数组")
|
||||
}
|
||||
list, ok := rawEvents.([]any)
|
||||
if !ok {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_events", "响应 events 字段不是数组")
|
||||
}
|
||||
rawHasMore, present := result["hasMore"]
|
||||
if !present {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_pagination", "响应缺少 hasMore 分页终态")
|
||||
}
|
||||
hasMore, ok := rawHasMore.(bool)
|
||||
if !ok {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_pagination", "响应 hasMore 字段不是布尔值")
|
||||
}
|
||||
next := ""
|
||||
if raw, present := result["nextCursor"]; present && raw != nil {
|
||||
var ok bool
|
||||
next, ok = raw.(string)
|
||||
if !ok {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_pagination", "响应 nextCursor 字段不是字符串")
|
||||
}
|
||||
next = strings.TrimSpace(next)
|
||||
}
|
||||
if hasMore && next == "" {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_next_cursor", "服务端返回 hasMore=true 但 nextCursor 为空")
|
||||
}
|
||||
if !hasMore && next != "" {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空")
|
||||
}
|
||||
events := make([]map[string]any, 0, len(list))
|
||||
for index, item := range list {
|
||||
event, ok := item.(map[string]any)
|
||||
if !ok || len(event) == 0 {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_event", fmt.Sprintf("events[%d] 不是非空对象", index))
|
||||
}
|
||||
if calendarSmartFirstString(event, "id", "eventId", "event_id") == "" {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_event_id", fmt.Sprintf("events[%d] 缺少稳定 id", index))
|
||||
}
|
||||
if _, ok := shortcutNextEventStart(event); !ok {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_event_start", fmt.Sprintf("events[%d] 缺少合法开始时间", index))
|
||||
}
|
||||
if _, ok := conflictsEndTime(event); !ok {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_event_end", fmt.Sprintf("events[%d] 缺少合法结束时间", index))
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, hasMore, next, nil
|
||||
}
|
||||
|
||||
func calendarSmartBusySlots(data map[string]any) ([]map[string]any, error) {
|
||||
data, err := calendarSmartRequireSuccess(data, "calendar/query_busy_status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, ok := data["result"].([]any)
|
||||
if !ok {
|
||||
return nil, calendarSmartError("calendar/query_busy_status", "missing_result", "响应 result 不是显式忙闲数组")
|
||||
}
|
||||
out := make([]map[string]any, 0)
|
||||
for entryIndex, entry := range entries {
|
||||
object, ok := entry.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, calendarSmartError("calendar/query_busy_status", "malformed_result_item", fmt.Sprintf("result[%d] 不是非空对象", entryIndex))
|
||||
}
|
||||
rawItems, present := object["scheduleItems"]
|
||||
if !present {
|
||||
return nil, calendarSmartError("calendar/query_busy_status", "missing_schedule_items", fmt.Sprintf("result[%d] 缺少 scheduleItems", entryIndex))
|
||||
}
|
||||
items, ok := rawItems.([]any)
|
||||
if !ok {
|
||||
return nil, calendarSmartError("calendar/query_busy_status", "malformed_schedule_items", fmt.Sprintf("result[%d].scheduleItems 不是数组", entryIndex))
|
||||
}
|
||||
for itemIndex, item := range items {
|
||||
schedule, ok := item.(map[string]any)
|
||||
if !ok || len(schedule) == 0 {
|
||||
return nil, calendarSmartError("calendar/query_busy_status", "malformed_schedule_item", fmt.Sprintf("scheduleItems[%d] 不是非空对象", itemIndex))
|
||||
}
|
||||
start := freebusyDateTime(schedule["start"])
|
||||
end := freebusyDateTime(schedule["end"])
|
||||
if start == nil || end == nil || strings.TrimSpace(fmt.Sprint(start)) == "" || strings.TrimSpace(fmt.Sprint(end)) == "" {
|
||||
return nil, calendarSmartError("calendar/query_busy_status", "malformed_schedule_time", "忙闲条目缺少开始或结束时间")
|
||||
}
|
||||
out = append(out, map[string]any{"start": start, "end": end})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func calendarSmartSuggestedSlots(data map[string]any) ([]map[string]any, error) {
|
||||
data, err := calendarSmartRequireSuccess(data, "calendar/list_suggested_event_times")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, calendarSmartError("calendar/list_suggested_event_times", "missing_result", "响应 result 不是对象")
|
||||
}
|
||||
rawSlots, present := result["recommendEventTimes"]
|
||||
if !present {
|
||||
return nil, calendarSmartError("calendar/list_suggested_event_times", "missing_suggestions", "响应缺少显式 recommendEventTimes 数组")
|
||||
}
|
||||
slots, ok := rawSlots.([]any)
|
||||
if !ok {
|
||||
return nil, calendarSmartError("calendar/list_suggested_event_times", "malformed_suggestions", "recommendEventTimes 不是数组")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(slots))
|
||||
for index, item := range slots {
|
||||
slot, ok := item.(map[string]any)
|
||||
if !ok || len(slot) == 0 {
|
||||
return nil, calendarSmartError("calendar/list_suggested_event_times", "malformed_suggestion", fmt.Sprintf("recommendEventTimes[%d] 不是非空对象", index))
|
||||
}
|
||||
start := slot["startTime"]
|
||||
end := slot["endTime"]
|
||||
if start == nil || end == nil {
|
||||
return nil, calendarSmartError("calendar/list_suggested_event_times", "malformed_suggestion_time", fmt.Sprintf("recommendEventTimes[%d] 缺少 startTime/endTime", index))
|
||||
}
|
||||
row := map[string]any{"start": start, "end": end}
|
||||
if raw, ok := slot["timeConflictAttendees"].([]any); ok {
|
||||
conflicts := make([]any, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
if value != nil {
|
||||
conflicts = append(conflicts, value)
|
||||
}
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
row["conflicts"] = conflicts
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func calendarSmartAttendees(data map[string]any) (map[string]bool, error) {
|
||||
data, err := calendarSmartRequireSuccess(data, "calendar/get_calendar_participants")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw any
|
||||
result, present := data["result"]
|
||||
if !present {
|
||||
return nil, calendarSmartError("calendar/get_calendar_participants", "missing_result", "参会人响应缺少 result")
|
||||
}
|
||||
switch typed := result.(type) {
|
||||
case []any:
|
||||
raw = typed
|
||||
case map[string]any:
|
||||
found := false
|
||||
for _, key := range []string{"attendees", "participants", "items", "list"} {
|
||||
if raw, found = typed[key]; found {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, calendarSmartError("calendar/get_calendar_participants", "missing_attendees", "响应缺少显式参会人数组")
|
||||
}
|
||||
default:
|
||||
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_result", "参会人响应 result 既不是数组也不是含参会人数组的对象")
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_attendees", "参会人字段不是数组")
|
||||
}
|
||||
identities := map[string]bool{}
|
||||
for index, item := range items {
|
||||
attendee, ok := item.(map[string]any)
|
||||
if !ok || len(attendee) == 0 {
|
||||
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_attendee", fmt.Sprintf("参会人第 %d 项不是非空对象", index))
|
||||
}
|
||||
id := calendarSmartFirstString(attendee, "userId", "user_id", "id", "staffId")
|
||||
name := calendarSmartFirstString(attendee, "displayName", "display_name", "name", "userName")
|
||||
if id == "" && name == "" {
|
||||
return nil, calendarSmartError("calendar/get_calendar_participants", "missing_attendee_identity", fmt.Sprintf("参会人第 %d 项既无 userId 也无 displayName", index))
|
||||
}
|
||||
if id != "" {
|
||||
identities[id] = true
|
||||
}
|
||||
if name != "" {
|
||||
identities[name] = true
|
||||
}
|
||||
if rawSelf, present := attendee["self"]; present {
|
||||
isSelf, ok := rawSelf.(bool)
|
||||
if !ok {
|
||||
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_attendee_self", fmt.Sprintf("参会人第 %d 项 self 不是布尔值", index))
|
||||
}
|
||||
if isSelf {
|
||||
identities["__self__"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func calendarSmartVerifyEventTimes(event map[string]any, start, end string) error {
|
||||
startValue, startOK := calendarSmartEventTime(event["start"])
|
||||
if !startOK {
|
||||
startValue, startOK = calendarSmartEventTime(event["startDateTime"])
|
||||
}
|
||||
endValue, endOK := calendarSmartEventTime(event["end"])
|
||||
if !endOK {
|
||||
endValue, endOK = calendarSmartEventTime(event["endDateTime"])
|
||||
}
|
||||
wantStart, startErr := time.Parse(time.RFC3339, start)
|
||||
wantEnd, endErr := time.Parse(time.RFC3339, end)
|
||||
if startErr != nil || endErr != nil || !startOK || !endOK || !startValue.Equal(wantStart) || !endValue.Equal(wantEnd) {
|
||||
return calendarSmartError("calendar/get_calendar_detail", "readback_time_mismatch", "写后读回的开始/结束时间与请求不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func calendarSmartEventTime(value any) (time.Time, bool) {
|
||||
if object, ok := value.(map[string]any); ok {
|
||||
value = object["dateTime"]
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, text)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func calendarSmartNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
for _, marker := range []string{"not found", "not_found", "不存在", "未找到", "404"} {
|
||||
if strings.Contains(message, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func calendarSmartValidateRange(start, end string) error {
|
||||
startTime, startErr := time.Parse(time.RFC3339, strings.TrimSpace(start))
|
||||
if startErr != nil {
|
||||
return apperrors.NewValidation("--start 时间格式无效,请使用 RFC3339/ISO8601")
|
||||
}
|
||||
endTime, endErr := time.Parse(time.RFC3339, strings.TrimSpace(end))
|
||||
if endErr != nil {
|
||||
return apperrors.NewValidation("--end 时间格式无效,请使用 RFC3339/ISO8601")
|
||||
}
|
||||
if !endTime.After(startTime) {
|
||||
return apperrors.NewValidation("--end 必须晚于 --start")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func calendarSmartWriteReceipt(data map[string]any, operation string) error {
|
||||
_, err := calendarSmartRequireSuccess(data, operation)
|
||||
return err
|
||||
}
|
||||
|
||||
func calendarSmartVerifyCreatedEvent(event map[string]any, eventID, title, start, end string) error {
|
||||
if id := calendarSmartFirstString(event, "id", "eventId", "event_id"); id != eventID {
|
||||
return calendarSmartError("calendar/get_calendar_detail", "readback_event_id_mismatch", "创建后读回的日程 id 与创建回执不一致")
|
||||
}
|
||||
if got := calendarSmartFirstString(event, "summary", "title"); got != strings.TrimSpace(title) {
|
||||
return calendarSmartError("calendar/get_calendar_detail", "readback_title_mismatch", "创建后读回的日程标题与请求不一致")
|
||||
}
|
||||
return calendarSmartVerifyEventTimes(event, start, end)
|
||||
}
|
||||
|
||||
func calendarSmartVerifyAttendees(present map[string]bool, expectedIDs, expectedNames []string, currentUserID string) error {
|
||||
for index, id := range expectedIDs {
|
||||
name := ""
|
||||
if index < len(expectedNames) {
|
||||
name = expectedNames[index]
|
||||
}
|
||||
isCurrentUser := currentUserID != "" && id == currentUserID && present["__self__"]
|
||||
if !present[id] && (name == "" || !present[name]) && !isCurrentUser {
|
||||
return calendarSmartError("calendar/get_calendar_participants", "readback_attendee_missing", "写后读回未找到请求添加的参会人")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func calendarSmartCurrentUserID(rt *shortcut.RuntimeContext, attendees map[string]bool) (string, error) {
|
||||
if !attendees["__self__"] {
|
||||
return "", nil
|
||||
}
|
||||
profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
userID := myAttendanceCurrentUserID(profile)
|
||||
if userID == "" {
|
||||
return "", calendarSmartError("contact/get_current_user_profile", "missing_current_user_id", "参会人读回只标记 self,但无法解析当前用户 userId")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func calendarSmartDeleteAndVerify(rt *shortcut.RuntimeContext, eventID string) error {
|
||||
written, err := rt.CallMCPWriteDataStrict("calendar", "delete_calendar_event", map[string]any{"eventId": eventID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := calendarSmartWriteReceipt(written, "calendar/delete_calendar_event"); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{"eventId": eventID})
|
||||
if err != nil {
|
||||
if calendarSmartNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return calendarSmartError("calendar/get_calendar_detail", "delete_readback_unknown", "删除后读回失败,无法证明日程已不存在")
|
||||
}
|
||||
event, err := calendarSmartRequireEvent(data, "calendar/get_calendar_detail", eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status := strings.ToLower(calendarSmartFirstString(event, "status", "eventStatus", "event_status"))
|
||||
if status == "cancelled" || status == "canceled" || status == "deleted" {
|
||||
return nil
|
||||
}
|
||||
return calendarSmartError("calendar/get_calendar_detail", "delete_readback_present", "删除后读回日程仍然存在")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 calendarSmartTestStep struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
type calendarSmartTestCall struct {
|
||||
key string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type calendarSmartTestCaller struct {
|
||||
steps map[string][]calendarSmartTestStep
|
||||
counts map[string]int
|
||||
calls []calendarSmartTestCall
|
||||
}
|
||||
|
||||
func (c *calendarSmartTestCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if c.counts == nil {
|
||||
c.counts = map[string]int{}
|
||||
}
|
||||
key := product + "/" + tool
|
||||
c.counts[key]++
|
||||
c.calls = append(c.calls, calendarSmartTestCall{key: key, args: args})
|
||||
steps := c.steps[key]
|
||||
index := c.counts[key] - 1
|
||||
if index >= len(steps) {
|
||||
return nil, errors.New("unexpected fixture call: " + key)
|
||||
}
|
||||
step := steps[index]
|
||||
if step.err != nil {
|
||||
return nil, step.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: step.text}}}, nil
|
||||
}
|
||||
|
||||
func (c *calendarSmartTestCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, product, tool, args)
|
||||
}
|
||||
|
||||
func (*calendarSmartTestCaller) Format() string { return "json" }
|
||||
func (*calendarSmartTestCaller) DryRun() bool { return false }
|
||||
func (*calendarSmartTestCaller) Fields() string { return "" }
|
||||
func (*calendarSmartTestCaller) JQ() string { return "" }
|
||||
|
||||
func runCalendarSmartCLI(t *testing.T, caller edition.ToolCaller, args ...string) (map[string]any, string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(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("calendar output is not exactly one JSON value: %q: %v", stdout.String(), decodeErr)
|
||||
}
|
||||
if data, ok := payload["data"].(map[string]any); ok {
|
||||
return data, stdout.String(), err
|
||||
}
|
||||
return payload, stdout.String(), err
|
||||
}
|
||||
|
||||
func calendarSmartTestEvent(id string) map[string]any {
|
||||
return map[string]any{
|
||||
"id": id,
|
||||
"summary": "fixture event",
|
||||
"start": map[string]any{"dateTime": "2026-08-17T09:00:00+08:00"},
|
||||
"end": map[string]any{"dateTime": "2026-08-17T10:00:00+08:00"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartResultContracts(t *testing.T) {
|
||||
shortcuts := []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
result bool
|
||||
}{
|
||||
{"today", Today.OutputRollout, Today.Contract.Result != nil},
|
||||
{"tomorrow", Tomorrow.OutputRollout, Tomorrow.Contract.Result != nil},
|
||||
{"week", Week.OutputRollout, Week.Contract.Result != nil},
|
||||
{"next-event", NextEvent.OutputRollout, NextEvent.Contract.Result != nil},
|
||||
{"conflicts", Conflicts.OutputRollout, Conflicts.Contract.Result != nil},
|
||||
{"free-slots", FreeSlots.OutputRollout, FreeSlots.Contract.Result != nil},
|
||||
{"suggest-time", SuggestTime.OutputRollout, SuggestTime.Contract.Result != nil},
|
||||
{"book", Book.OutputRollout, Book.Contract.Result != nil},
|
||||
{"cancel-event", CancelEvent.OutputRollout, CancelEvent.Contract.Result != nil},
|
||||
{"invite", Invite.OutputRollout, Invite.Contract.Result != nil},
|
||||
{"reschedule", Reschedule.OutputRollout, Reschedule.Contract.Result != nil},
|
||||
{"free", FreeBusy.OutputRollout, FreeBusy.Contract.Result != nil},
|
||||
{"my-free", MyFree.OutputRollout, MyFree.Contract.Result != nil},
|
||||
}
|
||||
for _, item := range shortcuts {
|
||||
if item.rollout != output.RolloutUnifiedActive || !item.result {
|
||||
t.Errorf("%s rollout=%q result=%v", item.name, item.rollout, item.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
|
||||
valid := calendarSmartTestEvent("event-placeholder")
|
||||
items, hasMore, next, err := calendarSmartEventPage(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"events": []any{valid},
|
||||
"hasMore": true,
|
||||
"nextCursor": "cursor-placeholder",
|
||||
},
|
||||
})
|
||||
if err != nil || len(items) != 1 || !hasMore || next != "cursor-placeholder" {
|
||||
t.Fatalf("valid page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
|
||||
}
|
||||
items, hasMore, next, err = calendarSmartEventPage(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"events": []any{}, "hasMore": false},
|
||||
})
|
||||
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
|
||||
t.Fatalf("explicit empty page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
|
||||
}
|
||||
|
||||
invalidPages := []map[string]any{
|
||||
{},
|
||||
{"success": false, "result": map[string]any{"events": []any{}, "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"events": map[string]any{}, "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": true}},
|
||||
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": false, "nextCursor": "unexpected"}},
|
||||
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "event-placeholder"}}, "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}},
|
||||
}
|
||||
for index, page := range invalidPages {
|
||||
if _, _, _, err := calendarSmartEventPage(page); err == nil {
|
||||
t.Errorf("invalid page %d accepted: %#v", index, page)
|
||||
}
|
||||
}
|
||||
|
||||
allDay := map[string]any{
|
||||
"id": "all-day-placeholder",
|
||||
"start": map[string]any{"date": "2026-08-17"},
|
||||
"end": map[string]any{"date": "2026-08-18"},
|
||||
}
|
||||
if _, _, _, err := calendarSmartEventPage(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"events": []any{allDay}, "hasMore": false},
|
||||
}); err != nil {
|
||||
t.Fatalf("legitimate all-day event rejected: %v", err)
|
||||
}
|
||||
|
||||
if slots, err := calendarSmartBusySlots(map[string]any{"success": true, "result": []any{}}); err != nil || slots == nil || len(slots) != 0 {
|
||||
t.Fatalf("explicit empty busy slots=%#v err=%v", slots, err)
|
||||
}
|
||||
if _, err := calendarSmartBusySlots(map[string]any{"success": true, "result": []any{map[string]any{}}}); err == nil {
|
||||
t.Fatal("busy result item without scheduleItems accepted")
|
||||
}
|
||||
if slots, err := calendarSmartSuggestedSlots(map[string]any{"success": true, "result": map[string]any{"recommendEventTimes": []any{}}}); err != nil || slots == nil || len(slots) != 0 {
|
||||
t.Fatalf("explicit empty suggestions=%#v err=%v", slots, err)
|
||||
}
|
||||
if _, err := calendarSmartSuggestedSlots(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
|
||||
t.Fatal("missing recommendEventTimes accepted")
|
||||
}
|
||||
if _, err := calendarSmartSuggestedSlots(map[string]any{"success": true, "result": map[string]any{"recommendEventTimes": []any{map[string]any{"startTime": "x"}}}}); err == nil {
|
||||
t.Fatal("suggestion without endTime accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartPaginationIsCompleteOrFails(t *testing.T) {
|
||||
eventJSON := `{"id":"event-placeholder","summary":"fixture","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}}`
|
||||
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"calendar/list_calendar_events": {
|
||||
{text: `{"success":true,"result":{"events":[` + eventJSON + `],"hasMore":true,"nextCursor":"cursor-placeholder"}}`},
|
||||
{text: `{"success":true,"result":{"events":[],"hasMore":false}}`},
|
||||
},
|
||||
}}
|
||||
payload, _, err := runCalendarSmartCLI(t, caller, "calendar", "+today")
|
||||
if err != nil || payload["complete"] != true || payload["count"] != float64(1) {
|
||||
t.Fatalf("today payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if caller.counts["calendar/list_calendar_events"] != 2 {
|
||||
t.Fatalf("list calls=%#v", caller.counts)
|
||||
}
|
||||
if got := caller.calls[1].args["cursor"]; got != "cursor-placeholder" {
|
||||
t.Fatalf("second-page cursor=%#v calls=%#v", got, caller.calls)
|
||||
}
|
||||
|
||||
for name, responses := range map[string][]calendarSmartTestStep{
|
||||
"missing pagination": {{text: `{"success":true,"result":{"events":[]}}`}},
|
||||
"stalled cursor": {
|
||||
{text: `{"success":true,"result":{"events":[],"hasMore":true,"nextCursor":"cursor-placeholder"}}`},
|
||||
{text: `{"success":true,"result":{"events":[],"hasMore":true,"nextCursor":"cursor-placeholder"}}`},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
bad := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": responses}}
|
||||
payload, outputText, err := runCalendarSmartCLI(t, bad, "calendar", "+today")
|
||||
if err == nil || payload != nil || outputText != "" {
|
||||
t.Fatalf("incomplete list accepted payload=%#v output=%q err=%v", payload, outputText, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartWriteReceiptsAndReadback(t *testing.T) {
|
||||
const start = "2026-08-17T09:00:00+08:00"
|
||||
const end = "2026-08-17T10:00:00+08:00"
|
||||
const eventID = "event-placeholder"
|
||||
|
||||
book := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"` + eventID + `"}}`}},
|
||||
"calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"` + eventID + `","summary":"fixture title","start":{"dateTime":"` + start + `"},"end":{"dateTime":"` + end + `"}}}`}},
|
||||
}}
|
||||
payload, _, err := runCalendarSmartCLI(t, book, "calendar", "+book", "--title", "fixture title", "--start", start, "--end", end, "--yes")
|
||||
if err != nil || payload["success"] != true || payload["verified"] != true || payload["eventId"] != eventID {
|
||||
t.Fatalf("book payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
unknownWrite := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"calendar/create_calendar_event": {{text: `{"result":{"id":"` + eventID + `"}}`}},
|
||||
}}
|
||||
payload, outputText, err := runCalendarSmartCLI(t, unknownWrite, "calendar", "+book", "--title", "fixture title", "--start", start, "--end", end, "--yes")
|
||||
if err == nil || payload != nil || outputText != "" || unknownWrite.counts["calendar/get_calendar_detail"] != 0 {
|
||||
t.Fatalf("unknown create receipt accepted payload=%#v output=%q err=%v counts=%#v", payload, outputText, err, unknownWrite.counts)
|
||||
}
|
||||
|
||||
reschedule := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"calendar/get_calendar_detail": {
|
||||
{text: `{"success":true,"result":{"id":"` + eventID + `"}}`},
|
||||
{text: `{"success":true,"result":{"id":"` + eventID + `","start":{"dateTime":"` + start + `"},"end":{"dateTime":"` + end + `"}}}`},
|
||||
},
|
||||
"calendar/update_calendar_event": {{text: `{"success":true}`}},
|
||||
}}
|
||||
payload, _, err = runCalendarSmartCLI(t, reschedule, "calendar", "+reschedule", "--event", eventID, "--start", start, "--end", end, "--yes")
|
||||
if err != nil || payload["success"] != true || payload["verified"] != true {
|
||||
t.Fatalf("reschedule payload=%#v err=%v", payload, err)
|
||||
}
|
||||
updates := reschedule.calls
|
||||
var updateArgs map[string]any
|
||||
for _, call := range updates {
|
||||
if call.key == "calendar/update_calendar_event" {
|
||||
updateArgs = call.args
|
||||
}
|
||||
}
|
||||
if updateArgs["eventId"] != eventID || updateArgs["startDateTime"] != start || updateArgs["endDateTime"] != end {
|
||||
t.Fatalf("reschedule request=%#v", updateArgs)
|
||||
}
|
||||
|
||||
invite := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"contact/search_contact_by_key_word": {{text: `{"result":[{"userId":"user-placeholder","name":"fixture person"}]}`}},
|
||||
"calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"` + eventID + `"}}`}},
|
||||
"calendar/add_calendar_participant": {{text: `{"success":true}`}},
|
||||
"calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"userId":"user-placeholder"}]}`}},
|
||||
}}
|
||||
payload, _, err = runCalendarSmartCLI(t, invite, "calendar", "+invite", "--event", eventID, "--with", "fixture person", "--yes")
|
||||
if err != nil || payload["success"] != true || payload["verified"] != true || payload["invitedCount"] != float64(1) {
|
||||
t.Fatalf("invite payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartDeleteAndRollbackVerifyAbsence(t *testing.T) {
|
||||
const eventID = "event-placeholder"
|
||||
const sensitiveTitle = "fixture-sensitive-title"
|
||||
cancel := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"calendar/get_calendar_detail": {
|
||||
{text: `{"success":true,"result":{"id":"` + eventID + `","summary":"` + sensitiveTitle + `"}}`},
|
||||
{err: errors.New("event not found")},
|
||||
},
|
||||
"calendar/delete_calendar_event": {{text: `{"success":true}`}},
|
||||
}}
|
||||
payload, outputText, err := runCalendarSmartCLI(t, cancel, "calendar", "+cancel-event", "--event", eventID, "--yes")
|
||||
if err != nil || payload["success"] != true || payload["deleted"] != true || payload["verified"] != true {
|
||||
t.Fatalf("cancel payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if strings.Contains(outputText, sensitiveTitle) {
|
||||
t.Fatalf("pre-delete PII leaked into output: %q", outputText)
|
||||
}
|
||||
if cancel.counts["calendar/get_calendar_detail"] != 2 {
|
||||
t.Fatalf("cancel did not verify absence: %#v", cancel.counts)
|
||||
}
|
||||
|
||||
tombstone := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"calendar/get_calendar_detail": {
|
||||
{text: `{"success":true,"result":{"id":"` + eventID + `"}}`},
|
||||
{text: `{"success":true,"result":{"id":"` + eventID + `","status":"cancelled"}}`},
|
||||
},
|
||||
"calendar/delete_calendar_event": {{text: `{"success":true}`}},
|
||||
}}
|
||||
payload, _, err = runCalendarSmartCLI(t, tombstone, "calendar", "+cancel-event", "--event", eventID, "--yes")
|
||||
if err != nil || payload["success"] != true || payload["verified"] != true {
|
||||
t.Fatalf("cancel tombstone payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
unknownReadback := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"calendar/get_calendar_detail": {
|
||||
{text: `{"success":true,"result":{"id":"` + eventID + `"}}`},
|
||||
{err: errors.New("temporary readback failure")},
|
||||
},
|
||||
"calendar/delete_calendar_event": {{text: `{"success":true}`}},
|
||||
}}
|
||||
payload, outputText, err = runCalendarSmartCLI(t, unknownReadback, "calendar", "+cancel-event", "--event", eventID, "--yes")
|
||||
if err == nil || payload != nil || outputText != "" {
|
||||
t.Fatalf("unknown delete readback accepted payload=%#v output=%q err=%v", payload, outputText, err)
|
||||
}
|
||||
|
||||
rollback := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"contact/search_contact_by_key_word": {{text: `{"result":[{"userId":"user-placeholder","name":"fixture person"}]}`}},
|
||||
"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"` + eventID + `"}}`}},
|
||||
"calendar/add_calendar_participant": {{text: `{"success":false,"message":"fixture rejection"}`}},
|
||||
"calendar/delete_calendar_event": {{text: `{"success":true}`}},
|
||||
"calendar/get_calendar_detail": {{err: errors.New("event not found")}},
|
||||
}}
|
||||
payload, outputText, err = runCalendarSmartCLI(t, rollback, "calendar", "+book", "--title", "fixture title", "--start", "2026-08-17T09:00:00+08:00", "--end", "2026-08-17T10:00:00+08:00", "--with", "fixture person", "--yes")
|
||||
if err == nil || payload != nil || outputText != "" || rollback.counts["calendar/delete_calendar_event"] != 1 || rollback.counts["calendar/get_calendar_detail"] != 1 {
|
||||
t.Fatalf("rollback was not verified payload=%#v output=%q err=%v counts=%#v", payload, outputText, err, rollback.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartSharedFailureBranches(t *testing.T) {
|
||||
for _, response := range []map[string]any{{}, {"success": "yes"}, {"success": false}} {
|
||||
if _, err := calendarSmartRequireSuccess(response, "calendar/test"); err == nil {
|
||||
t.Fatalf("bad success envelope accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
for _, response := range []map[string]any{
|
||||
{"success": true},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"summary": "missing id"}},
|
||||
{"success": true, "result": map[string]any{"id": "wrong"}},
|
||||
} {
|
||||
if _, err := calendarSmartRequireEvent(response, "calendar/get", "event-1"); err == nil {
|
||||
t.Fatalf("bad event accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
if event, err := calendarSmartRequireEvent(map[string]any{"success": true, "result": map[string]any{"id": "event-1"}}, "calendar/get", "event-1"); err != nil || event["id"] != "event-1" {
|
||||
t.Fatalf("valid event=%#v %v", event, err)
|
||||
}
|
||||
if calendarSmartEventID(map[string]any{"eventId": "direct"}) != "direct" || calendarSmartEventID(map[string]any{"data": map[string]any{"id": "nested"}}) != "nested" || calendarSmartEventID(map[string]any{}) != "" {
|
||||
t.Fatal("event id discovery drift")
|
||||
}
|
||||
|
||||
invalidPages := []map[string]any{
|
||||
{"success": true, "result": []any{}},
|
||||
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": "bad"}},
|
||||
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": true, "nextCursor": 1}},
|
||||
{"success": true, "result": map[string]any{"events": []any{map[string]any{"summary": "no id", "start": map[string]any{"dateTime": "2026-08-17T09:00:00+08:00"}, "end": map[string]any{"dateTime": "2026-08-17T10:00:00+08:00"}}}, "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "x", "end": map[string]any{"dateTime": "2026-08-17T10:00:00+08:00"}}}, "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "x", "start": map[string]any{"dateTime": "2026-08-17T09:00:00+08:00"}}}, "hasMore": false}},
|
||||
}
|
||||
for index, response := range invalidPages {
|
||||
if _, _, _, err := calendarSmartEventPage(response); err == nil {
|
||||
t.Fatalf("bad page %d accepted", index)
|
||||
}
|
||||
}
|
||||
|
||||
for _, response := range []map[string]any{
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": []any{"bad"}},
|
||||
{"success": true, "result": []any{map[string]any{"other": true}}},
|
||||
{"success": true, "result": []any{map[string]any{"scheduleItems": map[string]any{}}}},
|
||||
{"success": true, "result": []any{map[string]any{"scheduleItems": []any{"bad"}}}},
|
||||
{"success": true, "result": []any{map[string]any{"scheduleItems": []any{map[string]any{"start": ""}}}}},
|
||||
} {
|
||||
if _, err := calendarSmartBusySlots(response); err == nil {
|
||||
t.Fatalf("bad busy slots accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
busy, err := calendarSmartBusySlots(map[string]any{"success": true, "result": []any{map[string]any{"scheduleItems": []any{map[string]any{"start": map[string]any{"dateTime": "s"}, "end": map[string]any{"date": "e"}}}}}})
|
||||
if err != nil || len(busy) != 1 {
|
||||
t.Fatalf("busy slots=%#v %v", busy, err)
|
||||
}
|
||||
|
||||
for _, response := range []map[string]any{
|
||||
{"success": true, "result": []any{}},
|
||||
{"success": true, "result": map[string]any{"recommendEventTimes": map[string]any{}}},
|
||||
{"success": true, "result": map[string]any{"recommendEventTimes": []any{"bad"}}},
|
||||
} {
|
||||
if _, err := calendarSmartSuggestedSlots(response); err == nil {
|
||||
t.Fatalf("bad suggested slots accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
slots, err := calendarSmartSuggestedSlots(map[string]any{"success": true, "result": map[string]any{"recommendEventTimes": []any{map[string]any{"startTime": "s", "endTime": "e", "timeConflictAttendees": []any{nil, "u"}}}}})
|
||||
if err != nil || len(slots) != 1 || len(slots[0]["conflicts"].([]any)) != 1 {
|
||||
t.Fatalf("suggested slots=%#v %v", slots, err)
|
||||
}
|
||||
|
||||
for _, response := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": "bad"},
|
||||
{"success": true, "result": map[string]any{"attendees": map[string]any{}}},
|
||||
{"success": true, "result": []any{"bad"}},
|
||||
{"success": true, "result": []any{map[string]any{"role": "none"}}},
|
||||
{"success": true, "result": []any{map[string]any{"userId": "u", "self": "yes"}}},
|
||||
} {
|
||||
if _, err := calendarSmartAttendees(response); err == nil {
|
||||
t.Fatalf("bad attendees accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
attendees, err := calendarSmartAttendees(map[string]any{"success": true, "result": map[string]any{"participants": []any{map[string]any{"userId": "u", "displayName": "name", "self": true}}}})
|
||||
if err != nil || !attendees["u"] || !attendees["name"] || !attendees["__self__"] {
|
||||
t.Fatalf("attendees=%#v %v", attendees, err)
|
||||
}
|
||||
|
||||
event := map[string]any{"startDateTime": "2026-08-17T09:00:00+08:00", "endDateTime": "2026-08-17T10:00:00+08:00"}
|
||||
if err := calendarSmartVerifyEventTimes(event, "2026-08-17T09:00:00+08:00", "2026-08-17T10:00:00+08:00"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := calendarSmartVerifyEventTimes(event, "bad", "2026-08-17T10:00:00+08:00"); err == nil {
|
||||
t.Fatal("bad expected event time accepted")
|
||||
}
|
||||
if _, ok := calendarSmartEventTime(1); ok {
|
||||
t.Fatal("non-string event time accepted")
|
||||
}
|
||||
if _, ok := calendarSmartEventTime("bad"); ok {
|
||||
t.Fatal("bad event time accepted")
|
||||
}
|
||||
if calendarSmartNotFound(nil) || !calendarSmartNotFound(errors.New("404 not found")) || calendarSmartNotFound(errors.New("temporary")) {
|
||||
t.Fatal("not-found classification drift")
|
||||
}
|
||||
for _, pair := range [][2]string{{"bad", "2026-08-17T10:00:00+08:00"}, {"2026-08-17T09:00:00+08:00", "bad"}, {"2026-08-17T10:00:00+08:00", "2026-08-17T09:00:00+08:00"}} {
|
||||
if err := calendarSmartValidateRange(pair[0], pair[1]); err == nil {
|
||||
t.Fatalf("bad range accepted: %#v", pair)
|
||||
}
|
||||
}
|
||||
if err := calendarSmartVerifyCreatedEvent(map[string]any{"id": "wrong", "summary": "title"}, "event-1", "title", "s", "e"); err == nil {
|
||||
t.Fatal("wrong created event id accepted")
|
||||
}
|
||||
if err := calendarSmartVerifyCreatedEvent(map[string]any{"id": "event-1", "summary": "wrong"}, "event-1", "title", "s", "e"); err == nil {
|
||||
t.Fatal("wrong created event title accepted")
|
||||
}
|
||||
if err := calendarSmartVerifyAttendees(map[string]bool{}, []string{"u"}, nil, ""); err == nil {
|
||||
t.Fatal("missing attendee accepted")
|
||||
}
|
||||
if err := calendarSmartVerifyAttendees(map[string]bool{"name": true}, []string{"u"}, []string{"name"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := calendarSmartVerifyAttendees(map[string]bool{"__self__": true}, []string{"u"}, nil, "u"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func calendarSmartRuntimeForTest(t *testing.T, caller edition.ToolCaller) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := &cobra.Command{Use: "calendar-test"}
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
return shortcut.RuntimeContextForTest(cmd, shortcut.Shortcut{Service: "calendar", Product: "calendar"})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartCurrentUserDeleteAndPageCap(t *testing.T) {
|
||||
for name, steps := range map[string][]calendarSmartTestStep{
|
||||
"call": {{err: errors.New("profile failure")}},
|
||||
"missing": {{text: `{"success":true,"result":{}}`}},
|
||||
"success": {{text: `{"success":true,"result":{"userId":"user-placeholder"}}`}},
|
||||
} {
|
||||
t.Run("profile-"+name, func(t *testing.T) {
|
||||
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": steps}}
|
||||
id, err := calendarSmartCurrentUserID(calendarSmartRuntimeForTest(t, caller), map[string]bool{"__self__": true})
|
||||
if name == "success" {
|
||||
if err != nil || id == "" {
|
||||
t.Fatalf("id=%q err=%v", id, err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("bad profile accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if id, err := calendarSmartCurrentUserID(calendarSmartRuntimeForTest(t, &calendarSmartTestCaller{}), map[string]bool{}); err != nil || id != "" {
|
||||
t.Fatalf("profile unnecessarily called: id=%q err=%v", id, err)
|
||||
}
|
||||
|
||||
deleteCases := map[string]*calendarSmartTestCaller{
|
||||
"write-call": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{err: errors.New("write")}}}},
|
||||
"receipt": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"result":{}}`}}}},
|
||||
"read-unknown": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{err: errors.New("temporary")}}}},
|
||||
"read-malformed": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: `{"success":true,"result":{}}`}}}},
|
||||
"read-not-found-response": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: `{"success":false,"message":"not found"}`}}}},
|
||||
"present": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"event-placeholder","status":"active"}}`}}}},
|
||||
"tombstone": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"event-placeholder","status":"deleted"}}`}}}},
|
||||
}
|
||||
for name, caller := range deleteCases {
|
||||
t.Run("delete-"+name, func(t *testing.T) {
|
||||
err := calendarSmartDeleteAndVerify(calendarSmartRuntimeForTest(t, caller), "event-placeholder")
|
||||
if name == "tombstone" || name == "read-not-found-response" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("bad delete path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
steps := make([]calendarSmartTestStep, calendarSmartMaxPages)
|
||||
for index := range steps {
|
||||
steps[index] = calendarSmartTestStep{text: fmt.Sprintf(`{"success":true,"result":{"events":[],"hasMore":true,"nextCursor":"cursor-%d"}}`, index+1)}
|
||||
}
|
||||
capCaller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": steps}}
|
||||
if _, err := calendarSmartListAll(calendarSmartRuntimeForTest(t, capCaller), map[string]any{}); err == nil {
|
||||
t.Fatal("calendar page cap accepted incomplete events")
|
||||
}
|
||||
callFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": {{err: errors.New("list")}}}}
|
||||
if _, err := calendarSmartListAll(calendarSmartRuntimeForTest(t, callFailure), map[string]any{}); err == nil {
|
||||
t.Fatal("calendar list transport failure accepted")
|
||||
}
|
||||
_ = calendarProjectEvents([]map[string]any{calendarSmartTestEvent("event-placeholder")})
|
||||
item := shortcut.Shortcut{}
|
||||
finalizeCalendarSmart(&item, "description")
|
||||
if item.Contract.Result == nil {
|
||||
t.Fatal("finalized smart shortcut missing result")
|
||||
}
|
||||
start, end := calendarDayRange(0)
|
||||
if !end.After(start) || end.Sub(start) < 23*time.Hour {
|
||||
t.Fatalf("day range=%v..%v", start, end)
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
// confirm-before-delete safety net so you never wipe the wrong eventId.
|
||||
//
|
||||
// Steps: first pull the event's detail via get_calendar_detail (so a bad or
|
||||
// stale eventId fails clearly before any destructive write, and the title is
|
||||
// surfaced back to the user). Then delete it via delete_calendar_event.
|
||||
// stale eventId fails clearly before any destructive write). Then delete it via
|
||||
// delete_calendar_event and verify the event is absent.
|
||||
// Replaces `calendar event get --id` (verify it's the right one) →
|
||||
// `calendar event delete --id` (destroy it).
|
||||
//
|
||||
@@ -43,7 +43,7 @@ var CancelEvent = shortcut.Shortcut{
|
||||
Product: "calendar",
|
||||
Description: "取消(删除)一个已有日程(删除前先确认它真实存在)",
|
||||
Intent: "当你想取消/删除一个已经存在的日程时使用;" +
|
||||
"内部先用 eventId 拉一次日程详情确认它真实存在并回显标题,再执行删除," +
|
||||
"内部先用 eventId 拉一次日程详情确认它真实存在,再执行删除并验证它已不存在," +
|
||||
"避免因 eventId 写错而误删别的日程。" +
|
||||
"如果 eventId 查不到会直接报错,不会盲目删除。" +
|
||||
"这是高危写操作,会真实删除该日程,框架会二次确认。",
|
||||
@@ -68,7 +68,7 @@ var CancelEvent = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "取消(删除)一个已有日程(删除前先确认它真实存在)",
|
||||
UseWhen: []string{"当你想取消/删除一个已经存在的日程时使用;内部先用 eventId 拉一次日程详情确认它真实存在并回显标题,再执行删除,避免因 eventId 写错而误删别的日程。如果 eventId 查不到会直接报错,不会盲目删除。这是高危写操作,会真实删除该日程,框架会二次确认。"},
|
||||
UseWhen: []string{"当你想取消/删除一个已经存在的日程时使用;内部先用 eventId 拉一次日程详情确认它真实存在,再执行删除并验证它已不存在,避免因 eventId 写错而误删别的日程。如果 eventId 查不到会直接报错,不会盲目删除。这是高危写操作,会真实删除该日程,框架会二次确认。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws calendar +cancel-event --event EVENT_ID"},
|
||||
},
|
||||
@@ -92,60 +92,35 @@ var CancelEvent = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := calendarSmartRequireEvent(detail, "calendar/get_calendar_detail", eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Defensively surface the event title so the user can see what is about to
|
||||
// be cancelled. Structure is unknown, so probe several candidate fields at
|
||||
// both the top level and under a nested detail/event/data wrapper; if none
|
||||
// match we simply skip the projection and proceed to delete.
|
||||
title := cancelEventExtractTitle(detail)
|
||||
if title != "" {
|
||||
_ = rt.Output(map[string]any{
|
||||
"eventId": eventID,
|
||||
"title": title,
|
||||
"action": "about_to_cancel",
|
||||
// Never print the pre-delete event detail: it may carry title, attendee,
|
||||
// location, or meeting-room PII and would also create a second JSON value.
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"success": true,
|
||||
"dryRun": true,
|
||||
"executed": false,
|
||||
"eventId": eventID,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2 — delete the event. eventId param copied verbatim from the
|
||||
// helper's `event delete` call site (delete_calendar_event).
|
||||
return rt.CallMCP("delete_calendar_event", map[string]any{
|
||||
"eventId": eventID,
|
||||
// Step 2 — require a terminal delete receipt and then prove absence.
|
||||
if err := calendarSmartDeleteAndVerify(rt, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"success": true,
|
||||
"eventId": eventID,
|
||||
"deleted": true,
|
||||
"verified": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// cancelEventExtractTitle defensively digs a human-readable event title out of
|
||||
// a get_calendar_detail response. It checks common title-ish keys at the top
|
||||
// level and inside a single nested wrapper (detail/event/data/result), and
|
||||
// returns "" when nothing usable is found.
|
||||
func cancelEventExtractTitle(data map[string]any) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
if t := cancelEventTitleFromMap(data); t != "" {
|
||||
return t
|
||||
}
|
||||
for _, k := range []string{"detail", "event", "data", "result"} {
|
||||
if nested, ok := data[k].(map[string]any); ok {
|
||||
if t := cancelEventTitleFromMap(nested); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cancelEventTitleFromMap(m map[string]any) string {
|
||||
for _, k := range []string{"summary", "title", "subject", "name"} {
|
||||
if v, ok := m[k].(string); ok {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&CancelEvent, "已删除并通过缺席读回验证的日程")
|
||||
shortcut.Register(CancelEvent)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
Command: "+chat-messages",
|
||||
Product: "chat",
|
||||
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||||
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
|
||||
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选的姓名、userId 或 openDingTalkId 混合入口:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选并保留 identity_unverified,可交付精确命中但不能把原值升级为已验证身份或作完整否定结论。--sender-query 只按姓名唯一解析,解析失败会抑制未过滤消息并返回错误。不传发送者条件时原样读取会话且不查询发送者身份。sender 展示名不参与身份比较;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
@@ -83,7 +83,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||||
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
|
||||
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选的姓名、userId 或 openDingTalkId 混合入口:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选并保留 identity_unverified,可交付精确命中但不能把原值升级为已验证身份或作完整否定结论。--sender-query 只按姓名唯一解析,解析失败会抑制未过滤消息并返回错误。不传发送者条件时原样读取会话且不查询发送者身份。sender 展示名不参与身份比较;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
@@ -104,8 +104,8 @@ var ChatMessages = shortcut.Shortcut{
|
||||
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
|
||||
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true},
|
||||
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录故障不阻断原值 userId 筛选"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口(可选,可重复或逗号分隔)"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录无法分类时按原值 userId 筛选并保留身份未验证状态"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口;解析失败时抑制未过滤消息并返回错误(可选,可重复或逗号分隔)"},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
|
||||
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"},
|
||||
@@ -418,7 +418,7 @@ func applyOptionalChatMessagesSenderFilter(
|
||||
payload["complete"] = false
|
||||
payload["partial"] = len(filtered) > 0
|
||||
}
|
||||
unverifiedSenderInputs := chatMessagesUnverifiedSenderInputs(filtered, filter.resolutions)
|
||||
unverifiedSenderInputs := chatMessagesUnverifiedSenderInputs(filter.resolutions)
|
||||
if len(unverifiedSenderInputs) > 0 {
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
failures = append(failures, map[string]any{
|
||||
@@ -470,18 +470,11 @@ func applyOptionalChatMessagesSenderFilter(
|
||||
}
|
||||
|
||||
func chatMessagesUnverifiedSenderInputs(
|
||||
messages []map[string]any,
|
||||
resolutions []targetresolver.UserResolution,
|
||||
) []string {
|
||||
observed := map[string]bool{}
|
||||
for _, message := range messages {
|
||||
if senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))); senderID != "" && senderID != "<nil>" {
|
||||
observed[senderID] = true
|
||||
}
|
||||
}
|
||||
values := make([]string, 0)
|
||||
for _, resolution := range resolutions {
|
||||
if targetresolver.IsUnverifiedUserIDResolution(resolution) && !observed[resolution.Selected.UserID] {
|
||||
if targetresolver.IsUnverifiedUserIDResolution(resolution) {
|
||||
values = append(values, resolution.Query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +631,7 @@ func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unverified mixed sender becomes verified by an exact sender id match", func(t *testing.T) {
|
||||
t.Run("unverified mixed sender remains unverified after an exact sender id match", func(t *testing.T) {
|
||||
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
|
||||
resolution := targetresolver.UserResolution{
|
||||
Status: targetresolver.StatusResolved,
|
||||
@@ -655,13 +655,18 @@ func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T)
|
||||
{"openMessageId": "other", "senderUserId": "other-user"},
|
||||
}, &filter,
|
||||
)
|
||||
if len(filtered) != 1 || payload["complete"] != true || len(payload["failures"].([]map[string]any)) != 0 {
|
||||
if len(filtered) != 1 || payload["complete"] != false || payload["partial"] != true ||
|
||||
payload["failedCount"] != 1 || len(payload["failures"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("filtered=%#v payload=%#v", filtered, payload)
|
||||
}
|
||||
identity := payload["identityResult"].(map[string]any)
|
||||
if identity["status"] != "evaluated" || identity["negativeConclusionAllowed"] != true {
|
||||
if identity["status"] != "identity_unverified" || identity["negativeConclusionAllowed"] != false {
|
||||
t.Fatalf("identityResult=%#v", identity)
|
||||
}
|
||||
filterResult := payload["senderFilter"].(map[string]any)
|
||||
if filterResult["status"] != "identity_unverified" {
|
||||
t.Fatalf("senderFilter=%#v", filterResult)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unverified mixed sender without a match blocks complete negative conclusion", func(t *testing.T) {
|
||||
|
||||
@@ -24,6 +24,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/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -65,6 +66,10 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
|
||||
}
|
||||
case "contact/get_current_user_profile":
|
||||
text = `{"result":{"userId":"u1"}}`
|
||||
case "todo/create_personal_todo":
|
||||
text = `{"success":true,"result":{"taskId":"todo-created"}}`
|
||||
case "todo/get_todo_detail":
|
||||
text = `{"success":true,"result":{"todoDetailModel":{"taskId":"todo-created","subject":"交周报","isDone":false}}}`
|
||||
case "im/search_groups":
|
||||
text = `{"result":[{"openConversationId":"cid-1","title":"项目冲刺"}]}`
|
||||
case "chat/list_conversation_message_v2":
|
||||
@@ -112,6 +117,8 @@ func (f *platformCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func newPlatformCoverageRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
|
||||
@@ -83,7 +83,7 @@ var Conflicts = shortcut.Shortcut{
|
||||
// Target day window in local time.
|
||||
dayStart, dayEnd := calendarDayRange(rt.Int("in-days"))
|
||||
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", map[string]any{
|
||||
events, err := calendarSmartListAll(rt, map[string]any{
|
||||
"startTime": dayStart.UnixMilli(),
|
||||
"endTime": dayEnd.UnixMilli(),
|
||||
"calendarId": "primary",
|
||||
@@ -100,7 +100,7 @@ var Conflicts = shortcut.Shortcut{
|
||||
end time.Time
|
||||
}
|
||||
var evs []ev
|
||||
for _, e := range shortcutNextEventList(data) {
|
||||
for _, e := range events {
|
||||
start, ok := shortcutNextEventStart(e)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -139,25 +139,33 @@ var Conflicts = shortcut.Shortcut{
|
||||
"conflictCount": len(conflicts),
|
||||
"hasConflict": len(conflicts) > 0,
|
||||
"conflicts": conflicts,
|
||||
"complete": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// conflictsEndTime parses an event's end time from end.dateTime (RFC3339).
|
||||
// conflictsEndTime parses a timed event or a legitimate all-day event.
|
||||
func conflictsEndTime(e map[string]any) (time.Time, bool) {
|
||||
end, ok := e["end"].(map[string]any)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
if ok {
|
||||
if dt, ok := end["dateTime"].(string); ok && dt != "" {
|
||||
t, err := time.Parse(time.RFC3339, dt)
|
||||
return t, err == nil
|
||||
}
|
||||
if date, ok := end["date"].(string); ok && date != "" {
|
||||
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
|
||||
return t, err == nil
|
||||
}
|
||||
}
|
||||
dt, ok := end["dateTime"].(string)
|
||||
if !ok || dt == "" {
|
||||
return time.Time{}, false
|
||||
for _, key := range []string{"endTime", "endDateTime"} {
|
||||
if dt, ok := e[key].(string); ok && dt != "" {
|
||||
t, err := time.Parse(time.RFC3339, dt)
|
||||
if err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, dt)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func conflictsLabel(title string, start, end time.Time) string {
|
||||
@@ -179,5 +187,6 @@ func conflictsMin(a, b time.Time) time.Time {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&Conflicts, "完整事件集上的时间冲突分析")
|
||||
shortcut.Register(Conflicts)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -39,10 +42,11 @@ import (
|
||||
//
|
||||
// dws todo +created-todos
|
||||
var CreatedTodos = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+created-todos",
|
||||
Product: "todo",
|
||||
Description: "列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的)",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+created-todos",
|
||||
Product: "todo",
|
||||
Description: "列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的)",
|
||||
Intent: "当你想快速看清『哪些待办是我自己创建/发起的』,而不是别人指派给我执行的待办时使用;" +
|
||||
"内部拉取你当前组织下角色为创建人(creator)的待办列表(roleTypes=[\"creator\"]," +
|
||||
"creator 是待办列表支持的角色枚举之一),再在本地把每条待办投影成标题(subject)、" +
|
||||
@@ -62,6 +66,7 @@ var CreatedTodos = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +created-todos",
|
||||
},
|
||||
Description: "列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的)",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"我创建的全部待办","properties":{"created":{"type":"array","description":"创建人角色待办","items":{"type":"object","description":"待办条目","additionalProperties":true}}},"required":["created"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
// a single-chat message via openDingTalkId. Replaces `contact +search-user`
|
||||
// (copy openDingTalkId) → `chat +messages-send --open-dingtalk-id <id>`.
|
||||
//
|
||||
// dws chat +dm --to 张三 --text "周报发我一下"
|
||||
// dws chat +dm --to 张三 --content "周报发我一下"
|
||||
var DM = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+dm",
|
||||
@@ -62,17 +62,18 @@ var DM = shortcut.Shortcut{
|
||||
AgentSummary: "按姓名直接给某人发单聊消息(自动解析唯一 openDingTalkId)",
|
||||
UseWhen: []string{"当你只知道对方姓名、想直接发一条单聊消息而不想先查 userId 时使用;内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +dm --to 张三 --text \"周报发我一下\""},
|
||||
Examples: []string{"dws chat +dm --to 张三 --content \"周报发我一下\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{renamedRequiredParam("content", "text")},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true, Aliases: []string{"text"}},
|
||||
shortcut.AIMessageTagFlag(),
|
||||
},
|
||||
Tips: []string{`dws chat +dm --to 张三 --text "周报发我一下"`},
|
||||
Tips: []string{`dws chat +dm --to 张三 --content "周报发我一下"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
text := rt.Str("text")
|
||||
text := rt.StrFirst("text", "content")
|
||||
|
||||
resolved, err := targetresolver.ResolveUser(
|
||||
rt,
|
||||
|
||||
@@ -33,7 +33,7 @@ import (
|
||||
// touching existing content. Params (nodeId/markdown/mode) are copied verbatim
|
||||
// from the update_document append call site in internal/helpers/doc.go.
|
||||
//
|
||||
// dws doc +doc-append --doc DOC_ID --text "今天的会议结论:下周一上线。"
|
||||
// dws doc +doc-append --doc DOC_ID --content "今天的会议结论:下周一上线。"
|
||||
var DocAppend = shortcut.Shortcut{
|
||||
Service: "doc",
|
||||
Command: "+doc-append",
|
||||
@@ -67,27 +67,28 @@ var DocAppend = shortcut.Shortcut{
|
||||
UseWhen: []string{"当你只想往一篇钉钉文档的最后面补一段文字、又不想动原有内容时使用;内部用文档更新的“追加(append)”模式,把你给的文本安全地拼到文档末尾,不需要你先去查文档块列表、算末尾位置或手工拼块结构。会真实写入文档内容。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{
|
||||
"dws doc +doc-append --doc DOC_ID --text \"补充说明:本方案已评审通过。\"",
|
||||
"dws doc +doc-append --doc \"https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>\" --text \"追加一行备注\"",
|
||||
"dws doc +doc-append --doc DOC_ID --content \"补充说明:本方案已评审通过。\"",
|
||||
"dws doc +doc-append --doc \"https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>\" --content \"追加一行备注\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{renamedRequiredParam("content", "text")},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "doc", Type: shortcut.FlagString, Desc: "文档 documentId / nodeId(或文档 URL/token)", Required: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "要追加到文档末尾的文本", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "要追加到文档末尾的文本", Required: true, Aliases: []string{"text"}},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws doc +doc-append --doc DOC_ID --text "补充说明:本方案已评审通过。"`,
|
||||
`dws doc +doc-append --doc "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --text "追加一行备注"`,
|
||||
`dws doc +doc-append --doc DOC_ID --content "补充说明:本方案已评审通过。"`,
|
||||
`dws doc +doc-append --doc "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --content "追加一行备注"`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
nodeID := strings.TrimSpace(rt.Str("doc"))
|
||||
if nodeID == "" {
|
||||
return apperrors.NewValidation("--doc 不能为空,请提供文档 documentId/nodeId 或文档 URL")
|
||||
}
|
||||
text := rt.Str("text")
|
||||
text := rt.StrFirst("text", "content")
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return apperrors.NewValidation("--text 不能为空,请提供要追加的文本")
|
||||
return apperrors.NewValidation("--content 不能为空,请提供要追加的文本")
|
||||
}
|
||||
|
||||
// update_document append: params copied verbatim from the helper's
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -45,16 +48,32 @@ import (
|
||||
//
|
||||
// dws todo +due-today
|
||||
var DueToday = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+due-today",
|
||||
Product: "todo",
|
||||
Description: "列出我今天到期的待办",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+due-today",
|
||||
Product: "todo",
|
||||
Description: "列出我今天到期的待办",
|
||||
Intent: "当你想快速看清自己今天(planFinishDate 落在今天 00:00 到次日 00:00 之间)到期的待办、方便安排一天的工作时使用;" +
|
||||
"内部按今天的本地时间窗,把 planFinishDateStart=今天0点、planFinishDateEnd=次日0点(毫秒时间戳)传给 get_user_todos_in_current_org 做服务端过滤," +
|
||||
"默认拉取你作为执行人(executor)的待办,可用 --role-types 覆盖角色范围,最后只打印这些今天到期待办的标题、状态、优先级、创建人、到期时间和任务 ID。" +
|
||||
"这与 +overdue(已过期)不同:+overdue 看的是已经过了截止时间的待办,本命令看的是今天当天到期的待办。" +
|
||||
"这是纯只读操作,只做列表与投影,不会修改或完成任何待办;若今天没有到期的待办则返回错误提示。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "todo", Name: "shortcut_due_today", CanonicalPath: "todo.shortcut_due_today", CLIPath: "todo +due-today", PrimaryCLIPath: "todo +due-today"},
|
||||
Description: "列出我今天到期的待办",
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed Todo composite: local-day window construction, strict full pagination and projection are owned by the executable shortcut."},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出我今天到期的待办",
|
||||
UseWhen: []string{"需要查看本地日历日今天到期的待办时"},
|
||||
AvoidWhen: []string{"查看已经逾期的任务时使用 todo +overdue"},
|
||||
Examples: []string{"dws todo +due-today"},
|
||||
},
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"今天到期的待办","properties":{"count":{"type":"integer","description":"任务数量"},"tasks":{"type":"array","description":"今天到期的任务","items":{"type":"object","description":"待办条目","additionalProperties":true}}},"required":["count","tasks"],"additionalProperties":false}`)},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{
|
||||
Name: "role-types",
|
||||
@@ -108,24 +127,24 @@ var DueToday = shortcut.Shortcut{
|
||||
// leaks outside [startMs, endMs).
|
||||
results := make([]map[string]any, 0, len(cards))
|
||||
for _, m := range cards {
|
||||
if due, ok := shortcutOverdueDueTime(m); ok { // reused from overdue.go
|
||||
if due < startMs || due >= endMs {
|
||||
continue
|
||||
}
|
||||
due, ok := shortcutOverdueDueTime(m)
|
||||
if !ok {
|
||||
return shortcutTodoResponseError("malformed_due_time", "今天到期结果缺少可解析的 dueTime")
|
||||
}
|
||||
if due < startMs || due >= endMs {
|
||||
continue
|
||||
}
|
||||
taskID := shortcutRelatedTaskID(m) // reused from related_tasks.go
|
||||
results = append(results, shortcutRelatedProject(m, taskID)) // reused from related_tasks.go
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return apperrors.NewValidation("今天没有到期的待办")
|
||||
}
|
||||
|
||||
// Step 4 — print the projected list.
|
||||
return rt.Output(map[string]any{"count": len(results), "tasks": results})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
DueToday.Contract.Selection.AgentSummary = DueToday.Description
|
||||
DueToday.Contract.Selection.UseWhen = []string{DueToday.Intent}
|
||||
shortcut.Register(DueToday)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ var FreeSlots = shortcut.Shortcut{
|
||||
|
||||
// List the day's events (a little wider than the work window so events
|
||||
// spanning the edges are captured).
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", map[string]any{
|
||||
events, err := calendarSmartListAll(rt, map[string]any{
|
||||
"startTime": day.UnixMilli(),
|
||||
"endTime": day.AddDate(0, 0, 1).UnixMilli(),
|
||||
"calendarId": "primary",
|
||||
@@ -114,7 +114,7 @@ var FreeSlots = shortcut.Shortcut{
|
||||
// Collect and clip busy intervals to the work window.
|
||||
type interval struct{ start, end time.Time }
|
||||
var busy []interval
|
||||
for _, e := range shortcutNextEventList(data) {
|
||||
for _, e := range events {
|
||||
s, ok := shortcutNextEventStart(e)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -156,6 +156,7 @@ var FreeSlots = shortcut.Shortcut{
|
||||
"slotCount": len(free),
|
||||
"freeSlots": free,
|
||||
"allBusy": len(free) == 0,
|
||||
"complete": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -170,5 +171,6 @@ func freeSlotEntry(start, end time.Time) map[string]any {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&FreeSlots, "完整事件集上计算的工作时段空档")
|
||||
shortcut.Register(FreeSlots)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,10 @@ var FreeBusy = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
busy := freebusySlots(data)
|
||||
busy, err := calendarSmartBusySlots(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"who": user.name,
|
||||
"userId": user.userID,
|
||||
@@ -112,31 +115,6 @@ var FreeBusy = shortcut.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// freebusySlots flattens a query_busy_status response (result[] → scheduleItems[]
|
||||
// → {start,end}.dateTime) into a flat list of {start,end} busy slots.
|
||||
func freebusySlots(data map[string]any) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
entries, _ := data["result"].([]any)
|
||||
for _, e := range entries {
|
||||
em, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items, _ := em["scheduleItems"].([]any)
|
||||
for _, it := range items {
|
||||
im, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"start": freebusyDateTime(im["start"]),
|
||||
"end": freebusyDateTime(im["end"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// freebusyDateTime pulls the readable timestamp out of a {date,dateTime,timeZone}
|
||||
// object, falling back to the raw value.
|
||||
func freebusyDateTime(v any) any {
|
||||
@@ -163,5 +141,6 @@ func freebusyParseMillis(flag, value string) (int64, error) {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&FreeBusy, "严格校验的指定用户忙闲时段")
|
||||
shortcut.Register(FreeBusy)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ var Invite = shortcut.Shortcut{
|
||||
// Resolve every participant name to a unique userId first, so an
|
||||
// unknown/ambiguous name fails before we touch the event.
|
||||
var userIDs []string
|
||||
var userNames []string
|
||||
for _, name := range strings.Split(rt.Str("with"), ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
@@ -94,20 +95,65 @@ var Invite = shortcut.Shortcut{
|
||||
return err
|
||||
}
|
||||
userIDs = append(userIDs, user.userID)
|
||||
userNames = append(userNames, user.name)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return apperrors.NewValidation("--with 需要至少一个有效的参会人姓名")
|
||||
}
|
||||
|
||||
// Batch-add all participants. eventId + attendeesToAdd copied verbatim
|
||||
// from the helper's `attendee add` call site (add_calendar_participant).
|
||||
return rt.CallMCP("add_calendar_participant", map[string]any{
|
||||
preflight, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{"eventId": eventID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := calendarSmartRequireEvent(preflight, "calendar/get_calendar_detail", eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"success": true,
|
||||
"dryRun": true,
|
||||
"executed": false,
|
||||
"eventId": eventID,
|
||||
"inviteeCount": len(userIDs),
|
||||
})
|
||||
}
|
||||
|
||||
// Batch-add all participants and require explicit success.
|
||||
written, err := rt.CallMCPWriteDataStrict("calendar", "add_calendar_participant", map[string]any{
|
||||
"eventId": eventID,
|
||||
"attendeesToAdd": userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := calendarSmartWriteReceipt(written, "calendar/add_calendar_participant"); err != nil {
|
||||
return err
|
||||
}
|
||||
participants, err := rt.CallMCPData("calendar", "get_calendar_participants", map[string]any{"eventId": eventID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present, err := calendarSmartAttendees(participants)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentUserID, err := calendarSmartCurrentUserID(rt, present)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := calendarSmartVerifyAttendees(present, userIDs, userNames, currentUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"success": true,
|
||||
"eventId": eventID,
|
||||
"invitedCount": len(userIDs),
|
||||
"verified": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&Invite, "已添加并通过参会人读回验证的邀请")
|
||||
shortcut.Register(Invite)
|
||||
}
|
||||
|
||||
@@ -111,9 +111,13 @@ func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
|
||||
root.SetOut(&buf)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(argv)
|
||||
if err := root.Execute(); err != nil {
|
||||
executed, err := root.ExecuteC()
|
||||
if err != nil {
|
||||
t.Fatalf("execute %v: %v", argv, err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(executed); err != nil {
|
||||
t.Fatalf("emit %v: %v", argv, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
@@ -173,14 +177,15 @@ func TestCreatedTodosProjectsCards(t *testing.T) {
|
||||
"get_user_todos_in_current_org": `{"result":{"todoCards":[
|
||||
{"subject":"todoA","taskId":"111","dueTime":1784686500000},
|
||||
{"subject":"todoB","taskId":"222"}
|
||||
]}}`,
|
||||
],"hasMore":false}}`,
|
||||
}}
|
||||
out := runShortcut(t, fake, "todo", "+created-todos", "--format", "json")
|
||||
var d map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &d); err != nil {
|
||||
t.Fatalf("output not json: %q", out)
|
||||
}
|
||||
created, _ := d["created"].([]any)
|
||||
payload, _ := d["data"].(map[string]any)
|
||||
created, _ := payload["created"].([]any)
|
||||
if len(created) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: 2 todos in backend, projection created=%d (%s)", len(created), out)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import (
|
||||
//
|
||||
// 2. query_busy_status for [start,end] (defaults to today 00:00→tomorrow 00:00
|
||||
// in local time), then project result[].scheduleItems[] to a flat
|
||||
// {start,end} busy list (reusing freebusySlots).
|
||||
// {start,end} busy list through the shared strict busy-response parser.
|
||||
//
|
||||
// dws calendar +my-free
|
||||
// dws calendar +my-free --start 2026-07-10T09:00:00+08:00 --end 2026-07-10T18:00:00+08:00
|
||||
@@ -118,7 +118,7 @@ var MyFree = shortcut.Shortcut{
|
||||
return apperrors.NewValidation("--end 必须晚于 --start")
|
||||
}
|
||||
|
||||
// Step 3 — query and project busy slots (reuse freebusySlots).
|
||||
// Step 3 — query and strictly project busy slots.
|
||||
data, err := rt.CallMCPData("calendar", "query_busy_status", map[string]any{
|
||||
"startTime": startMillis,
|
||||
"endTime": endMillis,
|
||||
@@ -127,7 +127,10 @@ var MyFree = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
busy := freebusySlots(data)
|
||||
busy, err := calendarSmartBusySlots(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"userId": userID,
|
||||
"busy": busy,
|
||||
@@ -137,5 +140,6 @@ var MyFree = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&MyFree, "严格校验的当前用户忙闲时段")
|
||||
shortcut.Register(MyFree)
|
||||
}
|
||||
|
||||
@@ -82,20 +82,20 @@ var NextEvent = shortcut.Shortcut{
|
||||
"endTime": now.Add(7 * 24 * time.Hour).UnixMilli(),
|
||||
}
|
||||
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", params)
|
||||
events, err := calendarSmartListAll(rt, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := shortcutNextEventPick(data, now)
|
||||
event := shortcutNextEventPick(events, now)
|
||||
if event == nil {
|
||||
return rt.Output(map[string]any{"event": nil, "message": "近 7 天无日程"})
|
||||
return rt.Output(map[string]any{"event": nil, "message": "近 7 天无日程", "complete": true})
|
||||
}
|
||||
|
||||
// Emit a structured event via rt.Output so it honours
|
||||
// --format/--jq/--fields (previously it printed a fixed text line and
|
||||
// ignored the output flags, unlike +today/+week).
|
||||
return rt.Output(map[string]any{"event": shortcutNextEventProject(event)})
|
||||
return rt.Output(map[string]any{"event": shortcutNextEventProject(event), "complete": true})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -103,10 +103,10 @@ var NextEvent = shortcut.Shortcut{
|
||||
// events (must carry an id) whose start time is at or after `now`, and returns
|
||||
// the one that starts soonest. Field access is fully defensive because the
|
||||
// response envelope and per-event shape are not guaranteed.
|
||||
func shortcutNextEventPick(data map[string]any, now time.Time) map[string]any {
|
||||
func shortcutNextEventPick(events []map[string]any, now time.Time) map[string]any {
|
||||
var best map[string]any
|
||||
var bestStart time.Time
|
||||
for _, e := range shortcutNextEventList(data) {
|
||||
for _, e := range events {
|
||||
id, _ := e["id"].(string)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
continue
|
||||
@@ -126,35 +126,7 @@ func shortcutNextEventPick(data map[string]any, now time.Time) map[string]any {
|
||||
return best
|
||||
}
|
||||
|
||||
// shortcutNextEventList flattens the events slice out of the common response
|
||||
// shapes ({result:{events:[...]}}, {events:[...]}, or a bare [...] under
|
||||
// result/data).
|
||||
func shortcutNextEventList(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
var raw []any
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
if ev, ok := result["events"].([]any); ok {
|
||||
raw = ev
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
if ev, ok := data["events"].([]any); ok {
|
||||
raw = ev
|
||||
}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shortcutNextEventStart pulls an event's start time as a time.Time, tolerating
|
||||
// either the nested {start:{dateTime: RFC3339}} shape or a flat string field.
|
||||
// shortcutNextEventStart parses a timed event or a legitimate all-day event.
|
||||
func shortcutNextEventStart(event map[string]any) (time.Time, bool) {
|
||||
if start, ok := event["start"].(map[string]any); ok {
|
||||
if dt, ok := start["dateTime"].(string); ok && dt != "" {
|
||||
@@ -162,6 +134,11 @@ func shortcutNextEventStart(event map[string]any) (time.Time, bool) {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
if date, ok := start["date"].(string); ok && date != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", date, time.Local); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"startTime", "startDateTime"} {
|
||||
if dt, ok := event[k].(string); ok && dt != "" {
|
||||
@@ -188,12 +165,8 @@ func shortcutNextEventProject(event map[string]any) map[string]any {
|
||||
if start, ok := shortcutNextEventStart(event); ok {
|
||||
item["start"] = start.Format("2006-01-02 15:04")
|
||||
}
|
||||
if end, ok := event["end"].(map[string]any); ok {
|
||||
if dt, ok := end["dateTime"].(string); ok && dt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, dt); err == nil {
|
||||
item["end"] = t.Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
if end, ok := conflictsEndTime(event); ok {
|
||||
item["end"] = end.Format("2006-01-02 15:04")
|
||||
}
|
||||
if loc, ok := event["location"].(string); ok && strings.TrimSpace(loc) != "" {
|
||||
item["location"] = loc
|
||||
@@ -202,5 +175,6 @@ func shortcutNextEventProject(event map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&NextEvent, "完整翻页后选出的最近日程或明确空结果")
|
||||
shortcut.Register(NextEvent)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -41,10 +43,11 @@ import (
|
||||
//
|
||||
// dws todo +overdue
|
||||
var Overdue = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+overdue",
|
||||
Product: "todo",
|
||||
Description: "列出我已过期未完成的待办",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+overdue",
|
||||
Product: "todo",
|
||||
Description: "列出我已过期未完成的待办",
|
||||
Intent: "当你想快速看清自己有哪些待办已经过了截止时间却还没做完、方便优先处理时使用;" +
|
||||
"内部先拉取你当前组织下作为执行人(executor)的待办列表,再在本地按「有截止时间(dueTime) 且早于当前时刻 且尚未完成」的条件筛选," +
|
||||
"最后只打印这些逾期待办的标题(subject)、截止时间(dueTime) 和任务 ID(taskId)。" +
|
||||
@@ -63,6 +66,7 @@ var Overdue = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +overdue",
|
||||
},
|
||||
Description: "列出我已过期未完成的待办",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"逾期未完成待办","properties":{"overdue":{"type":"array","description":"逾期未完成任务","items":{"type":"object","description":"待办条目","additionalProperties":true}}},"required":["overdue"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package smart
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
|
||||
// renamedRequiredParam preserves the pre-rename Schema property and
|
||||
// requiredness. InterfaceType intentionally remains unset because these
|
||||
// Shortcut parameters historically published only their Cobra string type.
|
||||
func renamedRequiredParam(name, property string) contract.ParamDecl {
|
||||
required := true
|
||||
return contract.ParamDecl{
|
||||
Name: name,
|
||||
Property: property,
|
||||
Required: &required,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPrimaryParamMigrationSmartPayloadCompatibility(t *testing.T) {
|
||||
commands := []struct {
|
||||
name string
|
||||
baseArgs []string
|
||||
wantProduct string
|
||||
wantTool string
|
||||
payloadKey string
|
||||
}{
|
||||
{
|
||||
name: "broadcast",
|
||||
baseArgs: []string{"chat", "+broadcast", "--to", "张三"},
|
||||
wantProduct: "chat",
|
||||
wantTool: "send_personal_message",
|
||||
payloadKey: "content",
|
||||
},
|
||||
{
|
||||
name: "dm",
|
||||
baseArgs: []string{"chat", "+dm", "--to", "张三"},
|
||||
wantProduct: "chat",
|
||||
wantTool: "send_personal_message",
|
||||
payloadKey: "content",
|
||||
},
|
||||
{
|
||||
name: "send-to-group",
|
||||
baseArgs: []string{"chat", "+send-to-group", "--group", "项目冲刺"},
|
||||
wantProduct: "chat",
|
||||
wantTool: "send_personal_message",
|
||||
payloadKey: "content",
|
||||
},
|
||||
{
|
||||
name: "doc-append",
|
||||
baseArgs: []string{"doc", "+doc-append", "--doc", "doc-1"},
|
||||
wantProduct: "doc",
|
||||
wantTool: "update_document",
|
||||
payloadKey: "markdown",
|
||||
},
|
||||
}
|
||||
spellings := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantValue string
|
||||
}{
|
||||
{name: "old-only", args: []string{"--text", "legacy-value"}, wantValue: "legacy-value"},
|
||||
{name: "new-only", args: []string{"--content", "legacy-value"}, wantValue: "legacy-value"},
|
||||
{
|
||||
name: "both-different-old-wins",
|
||||
args: []string{"--text", "legacy-value", "--content", "canonical-value"},
|
||||
wantValue: "legacy-value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, command := range commands {
|
||||
payloads := make(map[string]map[string]any, len(spellings))
|
||||
for _, spelling := range spellings {
|
||||
t.Run(command.name+"/"+spelling.name, func(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
args := append([]string(nil), command.baseArgs...)
|
||||
args = append(args, spelling.args...)
|
||||
args = append(args, "--yes")
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute %v: %v", args, err)
|
||||
}
|
||||
if len(fake.calls) == 0 {
|
||||
t.Fatal("shortcut made no MCP call")
|
||||
}
|
||||
call := fake.calls[len(fake.calls)-1]
|
||||
if call.product != command.wantProduct || call.tool != command.wantTool {
|
||||
t.Fatalf("last call = %s/%s, want %s/%s", call.product, call.tool, command.wantProduct, command.wantTool)
|
||||
}
|
||||
if got := smartPrimaryPayloadValue(t, call.args, command.payloadKey); got != spelling.wantValue {
|
||||
t.Fatalf("payload value = %q, want %q; args=%#v", got, spelling.wantValue, call.args)
|
||||
}
|
||||
payloads[spelling.name] = call.args
|
||||
})
|
||||
}
|
||||
if !reflect.DeepEqual(payloads["old-only"], payloads["new-only"]) {
|
||||
t.Errorf("%s old/new payloads differ: old=%#v new=%#v", command.name, payloads["old-only"], payloads["new-only"])
|
||||
}
|
||||
if !reflect.DeepEqual(payloads["old-only"], payloads["both-different-old-wins"]) {
|
||||
t.Errorf("%s old/both payloads differ: old=%#v both=%#v", command.name, payloads["old-only"], payloads["both-different-old-wins"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func smartPrimaryPayloadValue(t *testing.T, args map[string]any, key string) string {
|
||||
t.Helper()
|
||||
value, _ := args[key].(string)
|
||||
if key == "markdown" {
|
||||
return value
|
||||
}
|
||||
var content map[string]string
|
||||
if err := json.Unmarshal([]byte(value), &content); err != nil {
|
||||
t.Fatalf("decode markdown content %q: %v", value, err)
|
||||
}
|
||||
return content["text"]
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationSmartSurfaceAndContract(t *testing.T) {
|
||||
tests := []struct {
|
||||
service string
|
||||
command string
|
||||
}{
|
||||
{service: "chat", command: "+broadcast"},
|
||||
{service: "chat", command: "+dm"},
|
||||
{service: "chat", command: "+send-to-group"},
|
||||
{service: "doc", command: "+doc-append"},
|
||||
}
|
||||
|
||||
root := newPlatformCoverageRoot()
|
||||
for _, test := range tests {
|
||||
t.Run(test.service+"/"+test.command, func(t *testing.T) {
|
||||
cmd, _, err := root.Find([]string{test.service, test.command})
|
||||
if err != nil || cmd == nil {
|
||||
t.Fatalf("find command: %v", err)
|
||||
}
|
||||
canonical := cmd.Flags().Lookup("content")
|
||||
if canonical == nil || canonical.Hidden {
|
||||
t.Fatalf("canonical --content = %#v, want visible", canonical)
|
||||
}
|
||||
legacy := cmd.Flags().Lookup("text")
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("legacy --text = %#v, want hidden", legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "content" {
|
||||
t.Fatalf("--text alias_of = %#v, want content", got)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("--text alias origin = %#v", got)
|
||||
}
|
||||
|
||||
var help bytes.Buffer
|
||||
cmd.SetOut(&help)
|
||||
if err := cmd.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(help.String(), "--content") || strings.Contains(help.String(), "--text") {
|
||||
t.Fatalf("help does not expose only the new Primary:\n%s", help.String())
|
||||
}
|
||||
|
||||
spec := smartRegisteredShortcut(t, test.service, test.command)
|
||||
if len(spec.Contract.Parameters) != 1 {
|
||||
t.Fatalf("ParamDecls = %#v, want one", spec.Contract.Parameters)
|
||||
}
|
||||
param := spec.Contract.Parameters[0]
|
||||
if param.Name != "content" || param.Property != "text" ||
|
||||
param.Required == nil || !*param.Required || param.InterfaceType != "" {
|
||||
t.Fatalf("content ParamDecl = %#v", param)
|
||||
}
|
||||
for _, example := range append(append([]string(nil), spec.Tips...), spec.Contract.Selection.Examples...) {
|
||||
if strings.Contains(example, "--text") || !strings.Contains(example, "--content") {
|
||||
t.Fatalf("example does not recommend only --content: %q", example)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationDocAppendRejectsBlankContent(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("doc", "", "")
|
||||
cmd.Flags().String("content", "", "")
|
||||
cmd.Flags().String("text", "", "")
|
||||
if err := cmd.Flags().Set("doc", "doc-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("content", " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := DocAppend.Execute(shortcut.RuntimeContextForTest(cmd, DocAppend))
|
||||
if err == nil || !strings.Contains(err.Error(), "--content 不能为空") {
|
||||
t.Fatalf("blank --content error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func smartRegisteredShortcut(t *testing.T, service, command string) shortcut.Shortcut {
|
||||
t.Helper()
|
||||
for _, spec := range shortcut.All() {
|
||||
if spec.Service == service && spec.Command == command {
|
||||
return spec
|
||||
}
|
||||
}
|
||||
t.Fatalf("registered shortcut %s %s not found", service, command)
|
||||
return shortcut.Shortcut{}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -46,16 +50,33 @@ import (
|
||||
//
|
||||
// dws todo +related-tasks
|
||||
var RelatedTasks = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+related-tasks",
|
||||
Product: "todo",
|
||||
Description: "一次性列出与我相关的全部待办(我作为创建人/执行人/参与人三种角色的并集,按 taskId 去重)",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+get-related-tasks",
|
||||
Aliases: []string{"+related-tasks"},
|
||||
Product: "todo",
|
||||
Description: "一次性列出与我相关的全部待办(我作为创建人/执行人/参与人三种角色的并集,按 taskId 去重)",
|
||||
Intent: "当你想一次看清『所有和我有关的待办』——不管是我创建(creator)的、指派给我执行(executor)的、还是我作为参与人(participant)协作的——时使用;" +
|
||||
"内部默认拉取你当前组织下 roleTypes=[\"creator\",\"executor\",\"participant\"] 三种角色的待办并集(这三个值正是待办列表支持的角色枚举)," +
|
||||
"再在本地按任务 ID(taskId) 去重(同一条待办可能因多角色重复出现),把每条投影成标题、状态、优先级、创建人、计划完成时间和 taskId 打印出来。" +
|
||||
"可用 --role-types 以逗号分隔覆盖默认角色(取值 creator/executor/participant),可用 --status 透传 todoStatus 过滤状态。" +
|
||||
"这是纯只读操作,只做列表、去重与投影,不会创建或修改任何待办;若没有与你相关的待办则返回空列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "todo", Name: "shortcut_get_related_tasks", CanonicalPath: "todo.shortcut_get_related_tasks", CLIPath: "todo +get-related-tasks", PrimaryCLIPath: "todo +get-related-tasks", Aliases: []string{"todo +related-tasks"}},
|
||||
Description: "一次性列出与我相关的全部待办(创建人/执行人/参与人并集)",
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed Todo composite: strict full pagination, role union, deduplication and projection are owned by the executable shortcut."},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出与我相关的全部待办",
|
||||
UseWhen: []string{"需要查看我作为创建人、执行人或参与人的全部待办并集时"},
|
||||
AvoidWhen: []string{"只需默认执行人视角列表时使用 todo +get-my-tasks"},
|
||||
Examples: []string{"dws todo +get-related-tasks"},
|
||||
},
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"与当前用户相关的待办并集","properties":{"count":{"type":"integer","description":"去重后的任务数量"},"tasks":{"type":"array","description":"按 taskId 去重的任务","items":{"type":"object","description":"待办条目","additionalProperties":true}}},"required":["count","tasks"],"additionalProperties":false}`)},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{
|
||||
Name: "role-types",
|
||||
@@ -69,9 +90,9 @@ var RelatedTasks = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws todo +related-tasks`,
|
||||
`dws todo +related-tasks --role-types creator,executor`,
|
||||
`dws todo +related-tasks --status TODO`,
|
||||
`dws todo +get-related-tasks`,
|
||||
`dws todo +get-related-tasks --role-types creator,executor`,
|
||||
`dws todo +get-related-tasks --status TODO`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — build params. roleTypes defaults to the three-role union;
|
||||
@@ -117,10 +138,6 @@ var RelatedTasks = shortcut.Shortcut{
|
||||
results = append(results, shortcutRelatedProject(m, taskID))
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return apperrors.NewValidation("没有与你相关的待办(creator/executor/participant 三种角色下均为空)")
|
||||
}
|
||||
|
||||
// Step 3 — print the deduped, projected list.
|
||||
return rt.Output(map[string]any{"tasks": results, "count": len(results)})
|
||||
},
|
||||
@@ -203,5 +220,7 @@ func shortcutRelatedStr(m map[string]any, keys ...string) string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
RelatedTasks.Contract.Selection.AgentSummary = RelatedTasks.Description
|
||||
RelatedTasks.Contract.Selection.UseWhen = []string{RelatedTasks.Intent}
|
||||
shortcut.Register(RelatedTasks)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -21,7 +22,9 @@ import (
|
||||
|
||||
"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"
|
||||
todoshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
|
||||
)
|
||||
|
||||
// Remind: create a personal todo for YOURSELF with an optional due time,
|
||||
@@ -35,10 +38,11 @@ import (
|
||||
//
|
||||
// dws todo +remind --task "交周报" --at 2026-03-10T18:00:00+08:00
|
||||
var Remind = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+remind",
|
||||
Product: "todo",
|
||||
Description: "给自己创建一条带可选截止时间的待办",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+remind",
|
||||
Product: "todo",
|
||||
Description: "给自己创建一条带可选截止时间的待办",
|
||||
Intent: "当你想给自己记一件事、并(可选)设一个截止时间,又不想先查自己的 userId 时使用;" +
|
||||
"内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 只会按 ISO8601 写入截止时间 dueTime,不会创建独立提醒规则。会真实创建待办。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
@@ -55,6 +59,7 @@ var Remind = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +remind",
|
||||
},
|
||||
Description: "给自己创建一条带可选截止时间的待办",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的自用待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","verified"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -101,9 +106,21 @@ var Remind = shortcut.Shortcut{
|
||||
vo["dueTime"] = ms
|
||||
}
|
||||
|
||||
return rt.CallMCP("create_personal_todo", map[string]any{
|
||||
params := map[string]any{
|
||||
"PersonalTodoCreateVO": vo,
|
||||
})
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": rt.Str("task")})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", rt.Str("task"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "subject": rt.Str("task"), "verified": true})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestRemindShortcutWritesAtOnlyAsDueTime(t *testing.T) {
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute +remind: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want profile read plus todo create", fake.calls)
|
||||
if len(fake.calls) != 3 {
|
||||
t.Fatalf("tool calls = %#v, want profile read, todo create and detail verification", fake.calls)
|
||||
}
|
||||
call := fake.calls[1]
|
||||
if call.product != "todo" || call.tool != "create_personal_todo" {
|
||||
@@ -51,6 +51,9 @@ func TestRemindShortcutWritesAtOnlyAsDueTime(t *testing.T) {
|
||||
if _, exists := request["reminderRules"]; exists {
|
||||
t.Fatalf("unexpected reminderRules in %#v", request)
|
||||
}
|
||||
if got := fake.calls[2].tool; got != "get_todo_detail" {
|
||||
t.Fatalf("verification call = %q, want get_todo_detail", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemindShortcutRejectsInvalidAtBeforeTodoCreate(t *testing.T) {
|
||||
|
||||
@@ -87,27 +87,68 @@ var Reschedule = shortcut.Shortcut{
|
||||
if start == "" || end == "" {
|
||||
return apperrors.NewValidation("--start 与 --end 都必须提供(ISO8601 时间字符串)")
|
||||
}
|
||||
if err := calendarSmartValidateRange(start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1 — confirm the event exists. eventId param copied verbatim from the
|
||||
// helper's `event get` call site (get_calendar_detail).
|
||||
if _, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{
|
||||
preflight, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{
|
||||
"eventId": eventID,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := calendarSmartRequireEvent(preflight, "calendar/get_calendar_detail", eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"success": true,
|
||||
"dryRun": true,
|
||||
"executed": false,
|
||||
"eventId": eventID,
|
||||
"start": start,
|
||||
"end": end,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2 — update ONLY the time. eventId + startDateTime/endDateTime (ISO
|
||||
// strings, not millis) copied verbatim from the helper's `event update`
|
||||
// call site (update_calendar_event). No other field is passed, so nothing
|
||||
// else on the event is changed.
|
||||
return rt.CallMCP("update_calendar_event", map[string]any{
|
||||
written, err := rt.CallMCPWriteDataStrict("calendar", "update_calendar_event", map[string]any{
|
||||
"eventId": eventID,
|
||||
"startDateTime": start,
|
||||
"endDateTime": end,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := calendarSmartWriteReceipt(written, "calendar/update_calendar_event"); err != nil {
|
||||
return err
|
||||
}
|
||||
readback, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{"eventId": eventID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, err := calendarSmartRequireEvent(readback, "calendar/get_calendar_detail", eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := calendarSmartVerifyEventTimes(event, start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"success": true,
|
||||
"eventId": eventID,
|
||||
"verified": true,
|
||||
"event": event,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&Reschedule, "已改期并通过精确时间读回验证的日程")
|
||||
shortcut.Register(Reschedule)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import (
|
||||
// search hits through list_messages_by_ids in chunks of 50. A later-page or
|
||||
// enrichment failure never turns a partial result into a false success: the
|
||||
// output carries an explicit failure ledger and complete=false.
|
||||
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 执行,但无稳定 senderId 命中不得作完整否定结论。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
|
||||
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 执行并保留 identity_unverified,可交付精确命中但不能把原值升级为已验证身份或作完整否定结论。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
|
||||
"显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" +
|
||||
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
|
||||
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"
|
||||
@@ -86,7 +86,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群名或 openConversationId;可混合输入并逐项唯一解析"},
|
||||
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名;只接受 openConversationId"},
|
||||
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "显式按群名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
|
||||
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录故障不阻断原值 userId 查询"},
|
||||
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录无法分类时按原值 userId 查询并保留身份未验证状态"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;--senders 的兼容别名,保留同样的三态解析与安全降级语义"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
|
||||
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
|
||||
@@ -278,7 +278,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
return searchSenderScopeUnverifiedError(resolvedFilters.Senders, unverifiableMessageIDs)
|
||||
}
|
||||
}
|
||||
unverifiedSenderInputs := searchUnverifiedSenderInputs(messages, resolvedFilters.Senders)
|
||||
unverifiedSenderInputs := searchUnverifiedSenderInputs(resolvedFilters.Senders)
|
||||
if len(unverifiedSenderInputs) > 0 {
|
||||
failures = append(failures, map[string]any{
|
||||
"stage": "sender_identity_verification",
|
||||
@@ -701,18 +701,11 @@ func filterSearchSenderScope(
|
||||
}
|
||||
|
||||
func searchUnverifiedSenderInputs(
|
||||
messages []map[string]any,
|
||||
resolutions []targetresolver.UserResolution,
|
||||
) []string {
|
||||
observed := map[string]bool{}
|
||||
for _, message := range messages {
|
||||
if senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))); senderID != "" && senderID != "<nil>" {
|
||||
observed[senderID] = true
|
||||
}
|
||||
}
|
||||
values := make([]string, 0)
|
||||
for _, resolution := range resolutions {
|
||||
if targetresolver.IsUnverifiedUserIDResolution(resolution) && !observed[resolution.Selected.UserID] {
|
||||
if targetresolver.IsUnverifiedUserIDResolution(resolution) {
|
||||
values = append(values, resolution.Query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,9 +277,14 @@ func TestCrossPlatformCoverageSearchMsgStableUserIDContinuesWhenDirectoryIsUnava
|
||||
if _, exists := caller.calls[1].args["senderOpenDingTakIds"]; exists {
|
||||
t.Fatalf("unrelated candidate leaked into request: %#v", caller.calls[1].args)
|
||||
}
|
||||
if payload["complete"] != true || payload["count"] != float64(1) {
|
||||
if payload["complete"] != false || payload["count"] != float64(1) ||
|
||||
payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
scope := payload["senderScope"].(map[string]any)
|
||||
if scope["status"] != "identity_unverified" || scope["targetsResolved"] != false {
|
||||
t.Fatalf("senderScope=%#v", scope)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed sender preserves positive matches and blocks complete negative conclusion", func(t *testing.T) {
|
||||
@@ -298,11 +303,11 @@ func TestCrossPlatformCoverageSearchMsgStableUserIDContinuesWhenDirectoryIsUnava
|
||||
if got := caller.calls[1].args["senderUserIds"]; !reflect.DeepEqual(got, []string{"stable-user-id"}) {
|
||||
t.Fatalf("senderUserIds=%#v", got)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["complete"] != true || payload["failedCount"] != float64(0) {
|
||||
if payload["count"] != float64(1) || payload["complete"] != false || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
scope := payload["senderScope"].(map[string]any)
|
||||
if _, exists := scope["status"]; exists || scope["targetsResolved"] != true {
|
||||
if scope["status"] != "identity_unverified" || scope["targetsResolved"] != false {
|
||||
t.Fatalf("senderScope=%#v", scope)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ import (
|
||||
// group NAMES) — NOT `search_common_groups`, which searches by member nicknames
|
||||
// and cannot locate a group by its title.
|
||||
//
|
||||
// dws chat +send-to-group --group 项目冲刺 --text "今天 5 点前提交进度"
|
||||
// dws chat +send-to-group --group 项目冲刺 --content "今天 5 点前提交进度"
|
||||
var SendToGroup = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+send-to-group",
|
||||
@@ -63,18 +63,19 @@ var SendToGroup = shortcut.Shortcut{
|
||||
AgentSummary: "按群名或 openConversationId 直接给群发消息",
|
||||
UseWhen: []string{"当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +send-to-group --group 项目冲刺 --text \"今天 5 点前提交进度\""},
|
||||
Examples: []string{"dws chat +send-to-group --group 项目冲刺 --content \"今天 5 点前提交进度\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{renamedRequiredParam("content", "text")},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true},
|
||||
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true, Aliases: []string{"text"}},
|
||||
shortcut.AIMessageTagFlag(),
|
||||
},
|
||||
Tips: []string{`dws chat +send-to-group --group 项目冲刺 --text "今天 5 点前提交进度"`},
|
||||
Tips: []string{`dws chat +send-to-group --group 项目冲刺 --content "今天 5 点前提交进度"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
groupName := rt.Str("group")
|
||||
text := rt.Str("text")
|
||||
text := rt.StrFirst("text", "content")
|
||||
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, groupName, "")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
smartCoverageStart = "2026-08-17T09:00:00+08:00"
|
||||
smartCoverageEnd = "2026-08-17T10:00:00+08:00"
|
||||
smartCoverageEvent = `{"success":true,"result":{"id":"event-placeholder","summary":"fixture title","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}}}`
|
||||
smartTodoPageEmpty = `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
|
||||
)
|
||||
|
||||
func smartContact() calendarSmartTestStep {
|
||||
return calendarSmartTestStep{text: `{"result":[{"userId":"user-placeholder","name":"fixture person"}]}`}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartBookBranches(t *testing.T) {
|
||||
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+book", "--title", "x", "--start", smartCoverageEnd, "--end", smartCoverageStart, "--yes"); err == nil {
|
||||
t.Fatal("book accepted reversed range")
|
||||
}
|
||||
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+book", "--title", "x", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blankWith := &calendarSmartTestCaller{}
|
||||
if _, _, err := runCalendarSmartCLI(t, blankWith, "calendar", "+book", "--title", "x", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--with", " , ", "--yes"); err == nil {
|
||||
t.Fatal("blank invitee list accepted")
|
||||
}
|
||||
|
||||
for name, caller := range map[string]*calendarSmartTestCaller{
|
||||
"create-call": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{err: errors.New("create")}}}},
|
||||
"missing-id": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{text: `{"success":true,"result":{}}`}}}},
|
||||
"read-call": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/get_calendar_detail": {{err: errors.New("read")}}}},
|
||||
"read-shape": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/get_calendar_detail": {{text: `{"success":true,"result":{}}`}}}},
|
||||
"read-verify": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/get_calendar_detail": {{text: strings.Replace(smartCoverageEvent, "fixture title", "wrong", 1)}}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+book", "--title", "fixture title", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--yes"); err == nil {
|
||||
t.Fatal("bad book path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rollbackFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"contact/search_contact_by_key_word": {{text: smartContact().text}},
|
||||
"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}},
|
||||
"calendar/add_calendar_participant": {{text: `{"success":false}`}},
|
||||
"calendar/delete_calendar_event": {{err: errors.New("rollback")}},
|
||||
}}
|
||||
if _, _, err := runCalendarSmartCLI(t, rollbackFailure, "calendar", "+book", "--title", "fixture title", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--with", "fixture person", "--yes"); err == nil {
|
||||
t.Fatal("failed rollback accepted")
|
||||
}
|
||||
|
||||
participantCases := map[string]*calendarSmartTestCaller{
|
||||
"call": {steps: map[string][]calendarSmartTestStep{
|
||||
"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: smartCoverageEvent}}, "calendar/get_calendar_participants": {{err: errors.New("participants")}},
|
||||
}},
|
||||
"shape": {steps: map[string][]calendarSmartTestStep{
|
||||
"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: smartCoverageEvent}}, "calendar/get_calendar_participants": {{text: `{"success":true}`}},
|
||||
}},
|
||||
"profile": {steps: map[string][]calendarSmartTestStep{
|
||||
"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: smartCoverageEvent}}, "calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"displayName":"fixture person","self":true}]}`}}, "contact/get_current_user_profile": {{err: errors.New("profile")}},
|
||||
}},
|
||||
"missing": {steps: map[string][]calendarSmartTestStep{
|
||||
"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: smartCoverageEvent}}, "calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"userId":"other"}]}`}},
|
||||
}},
|
||||
}
|
||||
for name, caller := range participantCases {
|
||||
t.Run("participants-"+name, func(t *testing.T) {
|
||||
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+book", "--title", "fixture title", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--with", "fixture person", "--yes"); err == nil {
|
||||
t.Fatal("bad participant verification accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartInviteAndRescheduleBranches(t *testing.T) {
|
||||
preflight := `{"success":true,"result":{"id":"event-placeholder"}}`
|
||||
inviteDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, inviteDry, "calendar", "+invite", "--event", "event-placeholder", "--with", "fixture person", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, caller := range map[string]*calendarSmartTestCaller{
|
||||
"preflight-call": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{err: errors.New("preflight")}}}},
|
||||
"write-call": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{err: errors.New("write")}}}},
|
||||
"receipt": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"result":{}}`}}}},
|
||||
"participants-call": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_participants": {{err: errors.New("participants")}}}},
|
||||
"participants-shape": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_participants": {{text: `{"success":true}`}}}},
|
||||
"profile": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"displayName":"fixture person","self":true}]}`}}, "contact/get_current_user_profile": {{err: errors.New("profile")}}}},
|
||||
"missing": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"userId":"other"}]}`}}}},
|
||||
} {
|
||||
t.Run("invite-"+name, func(t *testing.T) {
|
||||
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+invite", "--event", "event-placeholder", "--with", "fixture person", "--yes"); err == nil {
|
||||
t.Fatal("bad invite path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rescheduleDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, rescheduleDry, "calendar", "+reschedule", "--event", "event-placeholder", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+reschedule", "--event", "event-placeholder", "--start", smartCoverageEnd, "--end", smartCoverageStart, "--yes"); err == nil {
|
||||
t.Fatal("reschedule accepted reversed range")
|
||||
}
|
||||
for name, caller := range map[string]*calendarSmartTestCaller{
|
||||
"preflight": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{err: errors.New("preflight")}}}},
|
||||
"write": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}}, "calendar/update_calendar_event": {{err: errors.New("write")}}}},
|
||||
"receipt": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}}, "calendar/update_calendar_event": {{text: `{"result":{}}`}}}},
|
||||
"read": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}, {err: errors.New("read")}}, "calendar/update_calendar_event": {{text: `{"success":true}`}}}},
|
||||
"shape": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}, {text: `{"success":true,"result":{}}`}}, "calendar/update_calendar_event": {{text: `{"success":true}`}}}},
|
||||
"times": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}, {text: `{"success":true,"result":{"id":"event-placeholder","start":{"dateTime":"2026-08-17T08:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}}}`}}, "calendar/update_calendar_event": {{text: `{"success":true}`}}}},
|
||||
} {
|
||||
t.Run("reschedule-"+name, func(t *testing.T) {
|
||||
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+reschedule", "--event", "event-placeholder", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--yes"); err == nil {
|
||||
t.Fatal("bad reschedule path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCalendarSmartReadCommands(t *testing.T) {
|
||||
if _, ok := conflictsEndTime(map[string]any{"endTime": smartCoverageEnd}); !ok {
|
||||
t.Fatal("top-level endTime was not parsed")
|
||||
}
|
||||
conflictEvents := `{"success":true,"result":{"events":[{"id":"a","summary":"","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}},{"id":"b","summary":"B","start":{"dateTime":"2026-08-17T09:30:00+08:00"},"end":{"dateTime":"2026-08-17T10:30:00+08:00"}}],"hasMore":false}}`
|
||||
for _, command := range []string{"+conflicts", "+free-slots", "+tomorrow", "+week"} {
|
||||
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": {{text: conflictEvents}}}}
|
||||
args := []string{"calendar", command}
|
||||
if command == "+free-slots" {
|
||||
args = append(args, "--from", "0", "--to", "24")
|
||||
}
|
||||
if _, _, err := runCalendarSmartCLI(t, caller, args...); err != nil {
|
||||
t.Fatalf("%s: %v", command, err)
|
||||
}
|
||||
}
|
||||
for name, response := range map[string]string{
|
||||
"empty": smartTodoPageEmpty,
|
||||
"future": `{"success":true,"result":{"events":[{"id":"future","summary":"future","start":{"dateTime":"2099-08-17T09:00:00+08:00"},"end":{"dateTime":"2099-08-17T10:00:00+08:00"}}],"hasMore":false}}`,
|
||||
} {
|
||||
t.Run("next-"+name, func(t *testing.T) {
|
||||
if name == "empty" {
|
||||
response = `{"success":true,"result":{"events":[],"hasMore":false}}`
|
||||
}
|
||||
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": {{text: response}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+next-event"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := shortcutNextEventPick([]map[string]any{{"id": ""}, {"id": "x", "start": "bad"}}, time.Now()); got != nil {
|
||||
t.Fatalf("invalid next event picked: %#v", got)
|
||||
}
|
||||
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+free-slots", "--from", "24", "--to", "1"); err == nil {
|
||||
t.Fatal("bad free-slot window accepted")
|
||||
}
|
||||
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+suggest-time", "--with", "fixture person", "--start", smartCoverageEnd, "--end", smartCoverageStart); err == nil {
|
||||
t.Fatal("suggest-time accepted reversed range")
|
||||
}
|
||||
suggest := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"contact/search_contact_by_key_word": {{text: smartContact().text}},
|
||||
"calendar/list_suggested_event_times": {{text: `{"success":true,"result":{"recommendEventTimes":[]}}`}},
|
||||
}}
|
||||
if _, _, err := runCalendarSmartCLI(t, suggest, "calendar", "+suggest-time", "--with", "fixture person", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--duration", "30"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}}}, "calendar", "+cancel-event", "--event", "event-placeholder", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoSmartWriteBranches(t *testing.T) {
|
||||
assignDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, assignDry, "todo", "+assign", "--task", "task", "--to", "fixture person", "--due", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, responses := range map[string]map[string][]calendarSmartTestStep{
|
||||
"call": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{err: errors.New("write")}}},
|
||||
"verify": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"result":{}}`}}},
|
||||
"success": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}},
|
||||
} {
|
||||
t.Run("assign-"+name, func(t *testing.T) {
|
||||
_, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{steps: responses}, "todo", "+assign", "--task", "task", "--to", "fixture person", "--yes")
|
||||
if name == "success" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("bad assign path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
multiDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, multiDry, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multiSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, multiSuccess, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multiFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{err: errors.New("write")}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, multiFailure, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--yes"); err == nil {
|
||||
t.Fatal("assign-multi write failure accepted")
|
||||
}
|
||||
|
||||
doneDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, doneDry, "todo", "+todo-done", "--task", "needle", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doneSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{text: `{"success":true}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","isDone":true}}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, doneSuccess, "todo", "+todo-done", "--task", "needle", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doneCall := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{err: errors.New("write")}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, doneCall, "todo", "+todo-done", "--task", "needle", "--yes"); err == nil {
|
||||
t.Fatal("todo-done write failure accepted")
|
||||
}
|
||||
doneVerify := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{text: `{"result":{}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, doneVerify, "todo", "+todo-done", "--task", "needle", "--yes"); err == nil {
|
||||
t.Fatal("todo-done unverifiable write accepted")
|
||||
}
|
||||
|
||||
remindDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": {{text: `{"result":{"userId":"user-placeholder"}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, remindDry, "todo", "+remind", "--task", "task", "--at", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remindSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": {{text: `{"result":{"userId":"user-placeholder"}}`}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, remindSuccess, "todo", "+remind", "--task", "task", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, step := range map[string]calendarSmartTestStep{
|
||||
"call": {err: errors.New("write")},
|
||||
"receipt": {text: `{"result":{}}`},
|
||||
} {
|
||||
t.Run("remind-"+name, func(t *testing.T) {
|
||||
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
|
||||
"contact/get_current_user_profile": {{text: `{"result":{"userId":"user-placeholder"}}`}},
|
||||
"todo/create_personal_todo": {step},
|
||||
}}
|
||||
if _, _, err := runCalendarSmartCLI(t, caller, "todo", "+remind", "--task", "task", "--yes"); err == nil {
|
||||
t.Fatal("bad remind write accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoSmartReadAndSharedBranches(t *testing.T) {
|
||||
for _, response := range []map[string]any{{}, {"success": false}, {"result": "bad"}, {"result": map[string]any{"todoCards": map[string]any{}}}} {
|
||||
if _, _, err := shortcutTodoCardsStrict(response); err == nil {
|
||||
t.Fatalf("bad todo cards accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
deep := map[string]any{"result": map[string]any{"result": map[string]any{"result": map[string]any{"result": map[string]any{"result": map[string]any{"result": map[string]any{}}}}}}}
|
||||
if _, _, err := shortcutTodoCardsStrict(deep); err == nil {
|
||||
t.Fatal("over-deep todo wrapper accepted")
|
||||
}
|
||||
steps := make([]calendarSmartTestStep, todoMaxPages)
|
||||
for index := range steps {
|
||||
steps[index] = calendarSmartTestStep{text: `{"success":true,"result":{"todoCards":[],"hasMore":true}}`}
|
||||
}
|
||||
capCaller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": steps}}
|
||||
if _, _, err := runCalendarSmartCLI(t, capCaller, "todo", "+created-todos"); err == nil {
|
||||
t.Fatal("todo page cap accepted incomplete data")
|
||||
}
|
||||
|
||||
dayStart, dayEnd := calendarDayRange(0)
|
||||
dueMillis := dayStart.Add(time.Hour).UnixMilli()
|
||||
due := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"inside","dueTime":` + fmtInt64(dueMillis) + `},{"taskId":"task-out","subject":"outside","dueTime":` + fmtInt64(dayEnd.UnixMilli()) + `}],"hasMore":false}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, due, "todo", "+due-today"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badDue := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"bad"}],"hasMore":false}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, badDue, "todo", "+due-today"); err == nil {
|
||||
t.Fatal("due-today accepted missing due time")
|
||||
}
|
||||
}
|
||||
|
||||
func fmtInt64(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
@@ -80,6 +80,9 @@ var SuggestTime = shortcut.Shortcut{
|
||||
`dws calendar +suggest-time --with 张三,李四,王五 --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00" --duration 30`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := calendarSmartValidateRange(rt.Str("start"), rt.Str("end")); err != nil {
|
||||
return err
|
||||
}
|
||||
// Step 1 — resolve every participant name to a unique userId.
|
||||
names := rt.StrSlice("with")
|
||||
if len(names) == 0 {
|
||||
@@ -127,46 +130,15 @@ var SuggestTime = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"suggestions": suggestTimeSlots(data)})
|
||||
suggestions, err := calendarSmartSuggestedSlots(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(suggestions), "suggestions": suggestions, "complete": true})
|
||||
},
|
||||
}
|
||||
|
||||
// suggestTimeSlots flattens a list_suggested_event_times response
|
||||
// (result.recommendEventTimes[]) into a clean {start,end,conflicts} list. Null
|
||||
// entries in timeConflictAttendees are dropped so "conflicts" only appears when
|
||||
// there is a real conflicting attendee.
|
||||
func suggestTimeSlots(data map[string]any) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
root, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
root = data
|
||||
}
|
||||
slots, _ := root["recommendEventTimes"].([]any)
|
||||
for _, s := range slots {
|
||||
sm, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{
|
||||
"start": sm["startTime"],
|
||||
"end": sm["endTime"],
|
||||
}
|
||||
if raw, ok := sm["timeConflictAttendees"].([]any); ok {
|
||||
conflicts := []any{}
|
||||
for _, c := range raw {
|
||||
if c != nil {
|
||||
conflicts = append(conflicts, c)
|
||||
}
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
row["conflicts"] = conflicts
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&SuggestTime, "严格校验的共同空闲建议时段")
|
||||
shortcut.Register(SuggestTime)
|
||||
}
|
||||
|
||||
@@ -84,14 +84,16 @@ var Today = shortcut.Shortcut{
|
||||
// Project each event to {title, start, end, location, eventId} via the
|
||||
// shared calendarProjectEvents (same output as +tomorrow/+week) instead
|
||||
// of dumping the raw 17-field event objects.
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", toolArgs)
|
||||
events, err := calendarSmartListAll(rt, toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"events": calendarProjectEvents(data)})
|
||||
projected := calendarProjectEvents(events)
|
||||
return rt.Output(map[string]any{"count": len(projected), "events": projected, "complete": true})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&Today, "完整翻页并严格校验的今日日程")
|
||||
shortcut.Register(Today)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -21,7 +22,9 @@ import (
|
||||
|
||||
"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"
|
||||
todoshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
|
||||
)
|
||||
|
||||
// TodoDone: mark one of MY todos complete by matching a keyword in its title.
|
||||
@@ -40,10 +43,11 @@ import (
|
||||
//
|
||||
// dws todo +todo-done --task 周报
|
||||
var TodoDone = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+todo-done",
|
||||
Product: "todo",
|
||||
Description: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+todo-done",
|
||||
Product: "todo",
|
||||
Description: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
|
||||
Intent: "当你只记得某条待办的标题关键词、想直接把它标记完成,却不想先翻列表复制 taskId 时使用;" +
|
||||
"内部先拉取你当前组织下作为执行人的待办列表,按标题(subject)包含关键词匹配:没匹配到会提示「没找到匹配待办」," +
|
||||
"匹配到多条会列出候选(标题+taskId)让你写得更精确,唯一命中时才把它标记为已完成。这会真实修改待办完成状态。",
|
||||
@@ -61,6 +65,7 @@ var TodoDone = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +todo-done",
|
||||
},
|
||||
Description: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的完成结果","properties":{"taskId":{"type":"string","description":"已完成待办 taskId"},"subject":{"type":"string","description":"已完成待办标题"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","verified"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -106,10 +111,20 @@ var TodoDone = shortcut.Shortcut{
|
||||
|
||||
// Step 3 — mark it done. taskId + isDone mirror helpers `todo task done`
|
||||
// (update_todo_done_status, isDone passed as a string).
|
||||
return rt.CallMCP("update_todo_done_status", map[string]any{
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "taskId": matches[0].taskID})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "update_todo_done_status", map[string]any{
|
||||
"taskId": matches[0].taskID,
|
||||
"isDone": "true",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := todoshortcut.VerifyDoneStatus(rt, data, matches[0].taskID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": matches[0].taskID, "subject": matches[0].subject, "verified": true})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -46,15 +48,79 @@ func shortcutListAllTodoCards(rt *shortcut.RuntimeContext, base map[string]any)
|
||||
params["pageNum"] = strconv.Itoa(page)
|
||||
params["pageSize"] = strconv.Itoa(todoPageSize)
|
||||
|
||||
data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", params)
|
||||
data, err := rt.CallMCPReadData("todo", "get_user_todos_in_current_org", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards, hasMore, err := shortcutTodoCardsStrict(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards := shortcutTodoCards(data)
|
||||
all = append(all, cards...)
|
||||
if len(cards) < todoPageSize {
|
||||
break
|
||||
if !hasMore {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
return nil, shortcutTodoResponseError("pagination_limit_reached", fmt.Sprintf("连续 %d 页仍返回 hasMore=true,拒绝把不完整列表当作完整结果", todoMaxPages))
|
||||
}
|
||||
|
||||
func shortcutTodoCardsStrict(data map[string]any) ([]map[string]any, bool, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, false, shortcutTodoResponseError("empty_tool_response", "服务返回空响应")
|
||||
}
|
||||
if raw, ok := data["success"]; ok {
|
||||
success, valid := raw.(bool)
|
||||
if !valid || !success {
|
||||
return nil, false, shortcutTodoResponseError("remote_failure", "响应 success 不是明确的 true")
|
||||
}
|
||||
}
|
||||
container := data
|
||||
for depth := 0; depth < 5; depth++ {
|
||||
if raw, ok := container["todoCards"]; ok {
|
||||
items, valid := raw.([]any)
|
||||
if !valid {
|
||||
return nil, false, shortcutTodoResponseError("malformed_collection", "todoCards 不是数组")
|
||||
}
|
||||
rawMore, exists := container["hasMore"]
|
||||
hasMore, valid := rawMore.(bool)
|
||||
if !exists || !valid {
|
||||
return nil, false, shortcutTodoResponseError("malformed_has_more", "响应缺少布尔 hasMore")
|
||||
}
|
||||
cards := make([]map[string]any, 0, len(items))
|
||||
for i, item := range items {
|
||||
card, valid := item.(map[string]any)
|
||||
if !valid {
|
||||
return nil, false, shortcutTodoResponseError("malformed_item", fmt.Sprintf("todoCards 第 %d 项不是对象", i))
|
||||
}
|
||||
if shortcutTodoTaskID(card) == "" {
|
||||
return nil, false, shortcutTodoResponseError("missing_stable_id", fmt.Sprintf("todoCards 第 %d 项缺少稳定 taskId", i))
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards, hasMore, nil
|
||||
}
|
||||
raw, ok := container["result"]
|
||||
if !ok {
|
||||
raw, ok = container["data"]
|
||||
}
|
||||
if !ok {
|
||||
return nil, false, shortcutTodoResponseError("missing_collection", "响应缺少 todoCards 容器")
|
||||
}
|
||||
child, valid := raw.(map[string]any)
|
||||
if !valid {
|
||||
return nil, false, shortcutTodoResponseError("malformed_wrapper", "result/data 包装层不是对象")
|
||||
}
|
||||
container = child
|
||||
}
|
||||
return nil, false, shortcutTodoResponseError("missing_collection", "响应包装过深或缺少 todoCards")
|
||||
}
|
||||
|
||||
func shortcutTodoResponseError(reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation("todo/get_user_todos_in_current_org"),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageTodoSmartPagerRequiresHasMoreAndStableItems(t *testing.T) {
|
||||
valid := map[string]any{"success": true, "result": map[string]any{"todoCards": []any{}, "hasMore": false, "page": float64(1), "size": float64(20)}}
|
||||
items, hasMore, err := shortcutTodoCardsStrict(valid)
|
||||
if err != nil || hasMore || len(items) != 0 {
|
||||
t.Fatalf("valid empty page = %#v, %v, %v", items, hasMore, err)
|
||||
}
|
||||
for name, response := range map[string]map[string]any{
|
||||
"missing cards": {"success": true, "result": map[string]any{"hasMore": false}},
|
||||
"missing hasMore": {"success": true, "result": map[string]any{"todoCards": []any{}}},
|
||||
"bad element": {"success": true, "result": map[string]any{"todoCards": []any{"bad"}, "hasMore": false}},
|
||||
"missing id": {"success": true, "result": map[string]any{"todoCards": []any{map[string]any{"subject": "x"}}, "hasMore": false}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := shortcutTodoCardsStrict(response); err == nil {
|
||||
t.Fatalf("malformed response accepted: %#v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsTodoSmartContractsAndRelatedAlias(t *testing.T) {
|
||||
if RelatedTasks.Command != "+get-related-tasks" || len(RelatedTasks.Aliases) != 1 || RelatedTasks.Aliases[0] != "+related-tasks" {
|
||||
t.Fatalf("related command/alias = %q / %#v", RelatedTasks.Command, RelatedTasks.Aliases)
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
result bool
|
||||
}{
|
||||
{"related", RelatedTasks.OutputRollout, RelatedTasks.Contract.Result != nil},
|
||||
{"due-today", DueToday.OutputRollout, DueToday.Contract.Result != nil},
|
||||
{"assign", Assign.OutputRollout, Assign.Contract.Result != nil},
|
||||
{"assign-multi", AssignMulti.OutputRollout, AssignMulti.Contract.Result != nil},
|
||||
{"created", CreatedTodos.OutputRollout, CreatedTodos.Contract.Result != nil},
|
||||
{"overdue", Overdue.OutputRollout, Overdue.Contract.Result != nil},
|
||||
{"todo-done", TodoDone.OutputRollout, TodoDone.Contract.Result != nil},
|
||||
{"remind", Remind.OutputRollout, Remind.Contract.Result != nil},
|
||||
} {
|
||||
if item.rollout != output.RolloutUnifiedActive || !item.result {
|
||||
t.Errorf("%s missing unified Result contract", item.name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(Assign.Intent, "创建") {
|
||||
t.Fatal("legacy +assign semantics must remain create-and-assign")
|
||||
}
|
||||
}
|
||||
@@ -81,14 +81,16 @@ var Tomorrow = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
// Same {title,start,end,location,eventId} projection as +today/+week.
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", toolArgs)
|
||||
events, err := calendarSmartListAll(rt, toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"events": calendarProjectEvents(data)})
|
||||
projected := calendarProjectEvents(events)
|
||||
return rt.Output(map[string]any{"count": len(projected), "events": projected, "complete": true})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&Tomorrow, "完整翻页并严格校验的明日日程")
|
||||
shortcut.Register(Tomorrow)
|
||||
}
|
||||
|
||||
@@ -98,16 +98,18 @@ var Week = shortcut.Shortcut{
|
||||
"endTime": startOfNextWeek.UnixMilli(),
|
||||
}
|
||||
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", params)
|
||||
events, err := calendarSmartListAll(rt, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Same {title,start,end,location,eventId} projection as +today/+tomorrow.
|
||||
return rt.Output(map[string]any{"events": calendarProjectEvents(data)})
|
||||
projected := calendarProjectEvents(events)
|
||||
return rt.Output(map[string]any{"count": len(projected), "events": projected, "complete": true})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeCalendarSmart(&Week, "完整翻页并严格校验的本周日程")
|
||||
shortcut.Register(Week)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package todo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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 (
|
||||
todoPageSize = 20
|
||||
todoMaxPages = 40
|
||||
)
|
||||
|
||||
func todoObjectResult(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 todoCollectionResult(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":"待办结果条目","additionalProperties":true}}},"required":["count",%q],"additionalProperties":true}`,
|
||||
description, collection, description, collection,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func todoPagedCollectionResult(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":"待办结果条目","additionalProperties":true}},"page":{"type":"integer","description":"当前页码;全量模式省略"},"size":{"type":"integer","description":"当前页大小;全量模式省略"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页;全量模式省略"},"complete":{"type":"boolean","description":"全量模式是否确认遍历耗尽"}},"required":["count",%q],"additionalProperties":false}`,
|
||||
description, collection, description, collection,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func requireTodoResponse(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, todoResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明请求成功")
|
||||
}
|
||||
if raw, ok := data["success"]; ok {
|
||||
success, valid := raw.(bool)
|
||||
if !valid {
|
||||
return nil, todoResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
||||
}
|
||||
if !success {
|
||||
return nil, todoResponseError(operation, "remote_failure", "服务明确返回 success=false")
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireTodoWriteReceipt(data map[string]any, operation string) error {
|
||||
data, err := requireTodoResponse(data, operation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
success, ok := data["success"].(bool)
|
||||
if !ok || !success {
|
||||
return todoResponseError(operation, "missing_success_receipt", "写响应没有明确的 success=true 回执")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireTodoCollection(data map[string]any, operation string, keys ...string) ([]map[string]any, error) {
|
||||
data, err := requireTodoResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, found, err := findTodoCollection(data, keys, 0)
|
||||
if err != nil {
|
||||
return nil, todoResponseError(operation, "malformed_collection", err.Error())
|
||||
}
|
||||
if !found {
|
||||
return nil, todoResponseError(operation, "missing_collection", "响应缺少预期列表容器")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for i, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, todoResponseError(operation, "malformed_item", fmt.Sprintf("列表第 %d 项不是对象", i))
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func todoHasMore(data map[string]any) (bool, error) {
|
||||
return findTodoHasMore(data, 0)
|
||||
}
|
||||
|
||||
func findTodoHasMore(m map[string]any, depth int) (bool, error) {
|
||||
if depth > 4 {
|
||||
return false, todoResponseError("todo/pagination", "missing_has_more", "响应缺少 hasMore,无法证明分页已完成")
|
||||
}
|
||||
if raw, ok := m["hasMore"]; ok {
|
||||
value, valid := raw.(bool)
|
||||
if !valid {
|
||||
return false, todoResponseError("todo/pagination", "malformed_has_more", "响应 hasMore 字段不是布尔值")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if raw, ok := m[key]; ok {
|
||||
child, valid := raw.(map[string]any)
|
||||
if !valid {
|
||||
return false, todoResponseError("todo/pagination", "malformed_wrapper", key+" 包装层不是对象")
|
||||
}
|
||||
return findTodoHasMore(child, depth+1)
|
||||
}
|
||||
}
|
||||
return false, todoResponseError("todo/pagination", "missing_has_more", "响应缺少 hasMore,无法证明分页已完成")
|
||||
}
|
||||
|
||||
func listAllTodoCards(rt *shortcut.RuntimeContext, base map[string]any, maxPages int) ([]map[string]any, error) {
|
||||
all := make([]map[string]any, 0)
|
||||
for page := 1; page <= maxPages; page++ {
|
||||
params := make(map[string]any, len(base)+2)
|
||||
for key, value := range base {
|
||||
params[key] = value
|
||||
}
|
||||
params["pageNum"] = strconv.Itoa(page)
|
||||
params["pageSize"] = strconv.Itoa(todoPageSize)
|
||||
data, err := rt.CallMCPReadData("todo", "get_user_todos_in_current_org", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageItems, err := getMyTasksProjectStrict(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasMore, err := todoHasMore(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, pageItems...)
|
||||
if !hasMore {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
return nil, todoResponseError("todo/get_user_todos_in_current_org", "pagination_limit_reached", fmt.Sprintf("连续 %d 页仍返回 hasMore=true,拒绝把不完整结果当作完整结果", maxPages))
|
||||
}
|
||||
|
||||
func findTodoCollection(m map[string]any, keys []string, depth int) ([]any, bool, error) {
|
||||
if depth > 4 {
|
||||
return nil, false, nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
if raw, ok := m[key]; ok {
|
||||
arr, valid := raw.([]any)
|
||||
if !valid {
|
||||
return nil, false, fmt.Errorf("%s 容器不是数组", key)
|
||||
}
|
||||
return arr, true, nil
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
raw, ok := m[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, valid := raw.([]any); valid {
|
||||
return arr, true, nil
|
||||
}
|
||||
child, valid := raw.(map[string]any)
|
||||
if !valid {
|
||||
return nil, false, fmt.Errorf("%s 包装层既不是对象也不是数组", key)
|
||||
}
|
||||
if arr, found, err := findTodoCollection(child, keys, depth+1); found || err != nil {
|
||||
return arr, found, err
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func requireTodoDetail(data map[string]any, operation, expectedTaskID string) (map[string]any, error) {
|
||||
data, err := requireTodoResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail, found, err := findTodoObject(data, []string{"todoDetailModel", "todo", "task"}, 0)
|
||||
if err != nil {
|
||||
return nil, todoResponseError(operation, "malformed_detail", err.Error())
|
||||
}
|
||||
if !found {
|
||||
// Some deployments return the detail itself under result/data.
|
||||
if result, ok := data["result"].(map[string]any); ok && todoStableString(result, "taskId", "todoId", "id") != "" {
|
||||
detail, found = result, true
|
||||
} else if todoStableString(data, "taskId", "todoId", "id") != "" {
|
||||
detail, found = data, true
|
||||
}
|
||||
}
|
||||
if !found || len(detail) == 0 {
|
||||
return nil, todoResponseError(operation, "missing_detail", "响应缺少有效待办详情对象")
|
||||
}
|
||||
actual := todoStableString(detail, "taskId", "todoId", "id")
|
||||
if actual == "" {
|
||||
return nil, todoResponseError(operation, "missing_stable_id", "待办详情缺少稳定 taskId")
|
||||
}
|
||||
if expectedTaskID != "" && actual != expectedTaskID {
|
||||
return nil, todoResponseError(operation, "identity_mismatch", "读回的 taskId 与请求不一致")
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func findTodoObject(m map[string]any, keys []string, depth int) (map[string]any, bool, error) {
|
||||
if depth > 4 {
|
||||
return nil, false, nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
if raw, ok := m[key]; ok {
|
||||
object, valid := raw.(map[string]any)
|
||||
if !valid {
|
||||
return nil, false, fmt.Errorf("%s 容器不是对象", key)
|
||||
}
|
||||
return object, true, nil
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
raw, ok := m[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
child, valid := raw.(map[string]any)
|
||||
if !valid {
|
||||
return nil, false, fmt.Errorf("%s 包装层不是对象", key)
|
||||
}
|
||||
if object, found, err := findTodoObject(child, keys, depth+1); found || err != nil {
|
||||
return object, found, err
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func todoCreatedTaskID(data map[string]any) string {
|
||||
return findTodoStableString(data, []string{"taskId", "todoId", "id"}, 0)
|
||||
}
|
||||
|
||||
func findTodoStableString(m map[string]any, keys []string, depth int) string {
|
||||
if depth > 5 {
|
||||
return ""
|
||||
}
|
||||
if value := todoStableString(m, keys...); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, key := range []string{"result", "data", "todoDetailModel", "todo", "task"} {
|
||||
if child, ok := m[key].(map[string]any); ok {
|
||||
if value := findTodoStableString(child, keys, depth+1); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func todoStableString(m map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
switch value := m[key].(type) {
|
||||
case string:
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
case json.Number:
|
||||
return value.String()
|
||||
case float64:
|
||||
if value == float64(int64(value)) {
|
||||
return strconv.FormatInt(int64(value), 10)
|
||||
}
|
||||
case int:
|
||||
return strconv.Itoa(value)
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readTodoDetail(rt *shortcut.RuntimeContext, taskID string) (map[string]any, error) {
|
||||
data, err := rt.CallMCPReadData("todo", "get_todo_detail", map[string]any{"taskId": taskID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return requireTodoDetail(data, "todo/get_todo_detail", taskID)
|
||||
}
|
||||
|
||||
// VerifyCreatedTodo is shared by Todo-owned smart shortcuts that create a
|
||||
// personal todo. It requires a terminal receipt, a stable taskId and a detail
|
||||
// read-back bound to that exact ID.
|
||||
func VerifyCreatedTodo(rt *shortcut.RuntimeContext, data map[string]any, operation, expectedSubject string) (string, map[string]any, error) {
|
||||
if err := requireTodoWriteReceipt(data, operation); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
taskID := todoCreatedTaskID(data)
|
||||
if taskID == "" {
|
||||
return "", nil, todoResponseError(operation, "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if subject, _ := detail["subject"].(string); expectedSubject != "" && subject != expectedSubject {
|
||||
return "", nil, todoResponseError(operation, "verification_mismatch", "创建后读回的标题不一致")
|
||||
}
|
||||
return taskID, detail, nil
|
||||
}
|
||||
|
||||
// VerifyDoneStatus requires a terminal write receipt and verifies isDone on a
|
||||
// detail read bound to the same taskId.
|
||||
func VerifyDoneStatus(rt *shortcut.RuntimeContext, data map[string]any, taskID string, expected bool) error {
|
||||
if err := requireTodoWriteReceipt(data, "todo/update_todo_done_status"); err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actual, ok := detail["isDone"].(bool)
|
||||
if !ok || actual != expected {
|
||||
return todoResponseError("todo/update_todo_done_status", "verification_mismatch", "写后读回 isDone 不一致或缺失")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func todoResponseError(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),
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user