Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee1a91b74 | ||
|
|
92c80f81f9 | ||
|
|
d245ea4c84 | ||
|
|
9e3a5d6fbd | ||
|
|
33623d09d9 | ||
|
|
b20055a0b5 | ||
|
|
fc05976d33 | ||
|
|
021da02474 | ||
|
|
a5b9e5a13f | ||
|
|
3dce49020e | ||
|
|
fac92c252e | ||
|
|
9f8c525008 | ||
|
|
207d4dd7e5 | ||
|
|
8db297fe4b | ||
|
|
dc2aec7696 | ||
|
|
9a6b7d4d41 | ||
|
|
404af112b7 | ||
|
|
5947016cc1 | ||
|
|
5425d1565f | ||
|
|
386426bb92 | ||
|
|
3cea671a54 | ||
|
|
f06ea4d9e2 | ||
|
|
93dbd768f7 | ||
|
|
03838a3430 | ||
|
|
0d34150333 | ||
|
|
9e8b58cbb6 | ||
|
|
fe724e96e8 | ||
|
|
f72979f4a9 | ||
|
|
0b012788c7 | ||
|
|
7b7bd556e9 | ||
|
|
d534ee242c | ||
|
|
90473284b8 | ||
|
|
07aa2c883a | ||
|
|
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: 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.
|
||||
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (必填)")
|
||||
|
||||
@@ -3413,10 +3413,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 +3434,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 +3456,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 +3492,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 +3573,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 +3623,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 +3645,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"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -4768,7 +4810,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
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 +4818,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")
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package todo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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 todoCompositeReason = "Reviewed Todo Shortcut composite: the executable CLI owns strict response validation, pagination, write/read-back verification, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
||||
|
||||
func todoContract(command, description, useWhen string, result *contract.ResultSpec, examples ...string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
cliPath := "todo " + command
|
||||
return corecmd.ContractDecl{
|
||||
Description: description,
|
||||
Result: result,
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "todo", Name: name, CanonicalPath: "todo." + name,
|
||||
CLIPath: cliPath, PrimaryCLIPath: cliPath,
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: todoCompositeReason},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{useWhen},
|
||||
AvoidWhen: []string{"需要底层未公开参数或原始响应时改用对应原子命令"},
|
||||
Examples: examples,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func todoWriteSafety(idempotency string) contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency}
|
||||
}
|
||||
|
||||
func todoReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
|
||||
func parseTodoMillis(flag, value string) (int64, error) {
|
||||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 ISO8601 时间", flag))
|
||||
}
|
||||
return parsed.UnixMilli(), nil
|
||||
}
|
||||
|
||||
func createTodo(rt *shortcut.RuntimeContext) error {
|
||||
title := rt.Str("title")
|
||||
executors := nonEmptyStrings(rt.StrSlice("executors"))
|
||||
if title == "" || len(executors) == 0 {
|
||||
return apperrors.NewValidation("--title 与至少一个 --executors 均为必填")
|
||||
}
|
||||
vo := map[string]any{"subject": title, "executorIds": executors}
|
||||
if rt.Changed("due") {
|
||||
millis, err := parseTodoMillis("due", rt.Str("due"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vo["dueTime"] = millis
|
||||
}
|
||||
if rt.Changed("priority") {
|
||||
priority := rt.Int("priority")
|
||||
if priority != 10 && priority != 20 && priority != 30 && priority != 40 {
|
||||
return apperrors.NewValidation("--priority 仅接受 10/20/30/40")
|
||||
}
|
||||
vo["priority"] = priority
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "create", "subject": title})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", map[string]any{"PersonalTodoCreateVO": vo})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireTodoWriteReceipt(data, "todo/create_personal_todo"); err != nil {
|
||||
return err
|
||||
}
|
||||
taskID := todoCreatedTaskID(data)
|
||||
if taskID == "" {
|
||||
return todoResponseError("todo/create_personal_todo", "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if subject, _ := detail["subject"].(string); subject != title {
|
||||
return todoResponseError("todo/create_personal_todo", "verification_mismatch", "创建后读回的标题不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
|
||||
}
|
||||
|
||||
var Create = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo", Command: "+create", Product: "todo",
|
||||
Description: "创建待办并读回验证", Intent: "创建一条个人待办;只有取得稳定 taskId 且详情读回一致才报告成功。",
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("non_idempotent"),
|
||||
Contract: todoContract("+create", "创建待办并读回验证", "标题和执行人已确定,需要创建待办时使用", todoObjectResult("已验证的新建待办"), `dws todo +create --title "提交报告" --executors <USER_ID>`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "待办标题", Required: true},
|
||||
{Name: "executors", Type: shortcut.FlagStringSlice, Desc: "执行人 userId", Required: true},
|
||||
{Name: "due", Type: shortcut.FlagString, Desc: "截止时间(ISO8601)"},
|
||||
{Name: "priority", Type: shortcut.FlagInt, Desc: "优先级 10/20/30/40"},
|
||||
},
|
||||
Execute: createTodo,
|
||||
}
|
||||
|
||||
func updateTodo(rt *shortcut.RuntimeContext) error {
|
||||
if !rt.Changed("title") && !rt.Changed("due") && !rt.Changed("priority") {
|
||||
return apperrors.NewValidation("至少指定 --title、--due、--priority 之一")
|
||||
}
|
||||
taskID := rt.Str("task-id")
|
||||
request := map[string]any{"taskId": taskID}
|
||||
if rt.Changed("title") {
|
||||
if rt.Str("title") == "" {
|
||||
return apperrors.NewValidation("--title 不能为空")
|
||||
}
|
||||
request["subject"] = rt.Str("title")
|
||||
}
|
||||
if rt.Changed("due") {
|
||||
millis, err := parseTodoMillis("due", rt.Str("due"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request["dueTime"] = millis
|
||||
}
|
||||
if rt.Changed("priority") {
|
||||
priority := rt.Int("priority")
|
||||
if priority != 10 && priority != 20 && priority != 30 && priority != 40 {
|
||||
return apperrors.NewValidation("--priority 仅接受 10/20/30/40")
|
||||
}
|
||||
request["priority"] = priority
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "update", "taskId": taskID})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "update_todo_task", map[string]any{"TodoUpdateRequest": request})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireTodoWriteReceipt(data, "todo/update_todo_task"); err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, expected := range request {
|
||||
if key == "taskId" {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(detail[key]) != fmt.Sprint(expected) {
|
||||
return todoResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
|
||||
}
|
||||
|
||||
var Update = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo", Command: "+update", Product: "todo",
|
||||
Description: "更新待办并读回验证", Intent: "按 taskId 修改标题、截止时间或优先级,并严格读回核验。",
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("idempotent"),
|
||||
Contract: todoContract("+update", "更新待办并读回验证", "已知 taskId 且需要修改待办字段时使用", todoObjectResult("已验证的更新结果"), `dws todo +update --task-id <TASK_ID> --title "新标题"`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "新标题"},
|
||||
{Name: "due", Type: shortcut.FlagString, Desc: "新截止时间(ISO8601)"},
|
||||
{Name: "priority", Type: shortcut.FlagInt, Desc: "新优先级 10/20/30/40"},
|
||||
},
|
||||
Execute: updateTodo,
|
||||
}
|
||||
|
||||
func setTodoDone(rt *shortcut.RuntimeContext, target bool) error {
|
||||
taskID := rt.Str("task-id")
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "taskId": taskID, "isDone": target})
|
||||
}
|
||||
before, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, ok := before["isDone"].(bool)
|
||||
if !ok {
|
||||
return todoResponseError("todo/get_todo_detail", "malformed_done_status", "待办详情缺少布尔 isDone,拒绝盲目写状态")
|
||||
}
|
||||
if current == target {
|
||||
return rt.Output(map[string]any{"taskId": taskID, "isDone": target, "verified": true, "alreadyInTargetState": true})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "update_todo_done_status", map[string]any{"taskId": taskID, "isDone": strconv.FormatBool(target)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireTodoWriteReceipt(data, "todo/update_todo_done_status"); err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
done, ok := detail["isDone"].(bool)
|
||||
if !ok || done != target {
|
||||
return todoResponseError("todo/update_todo_done_status", "verification_mismatch", "完成状态读回不一致或缺失")
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "isDone": target, "verified": true, "alreadyInTargetState": false})
|
||||
}
|
||||
|
||||
func doneShortcut(command string, target bool) shortcut.Shortcut {
|
||||
description := "完成待办并读回验证"
|
||||
if !target {
|
||||
description = "重新打开待办并读回验证"
|
||||
}
|
||||
return shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo", Command: command, Product: "todo", Description: description, Intent: description,
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("idempotent"),
|
||||
Contract: todoContract(command, description, "已知 taskId 且需要改变完成状态时使用", todoObjectResult("已验证的待办状态"), "dws todo "+command+" --task-id <TASK_ID>"),
|
||||
Flags: []shortcut.Flag{{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true}},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error { return setTodoDone(rt, target) },
|
||||
}
|
||||
}
|
||||
|
||||
var Complete = doneShortcut("+complete", true)
|
||||
var Reopen = doneShortcut("+reopen", false)
|
||||
|
||||
var Search = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo", Command: "+search", Product: "todo",
|
||||
Description: "搜索与我相关的全部待办", Intent: "分页遍历当前组织下与我相关的待办并按标题关键词匹配。",
|
||||
Risk: shortcut.RiskRead, Safety: todoReadSafety(),
|
||||
Contract: todoContract("+search", "搜索与我相关的全部待办", "需要按标题关键词跨全部分页搜索待办时使用", todoCollectionResult("todos", "待办搜索结果"), `dws todo +search --query "周报"`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "标题关键词", Required: true},
|
||||
{Name: "status", Type: shortcut.FlagString, Enum: []string{"true", "false"}, Desc: "完成状态"},
|
||||
{Name: "max-pages", Type: shortcut.FlagInt, Default: "40", Desc: "最大遍历页数(1-40)"},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
query := strings.ToLower(rt.Str("query"))
|
||||
if query == "" {
|
||||
return apperrors.NewValidation("--query 不能为空")
|
||||
}
|
||||
maxPages := rt.Int("max-pages")
|
||||
if maxPages < 1 || maxPages > todoMaxPages {
|
||||
return apperrors.NewValidation("--max-pages 必须是 1 到 40")
|
||||
}
|
||||
base := map[string]any{"roleTypes": []string{"creator", "executor", "participant"}}
|
||||
if rt.Changed("status") {
|
||||
base["todoStatus"] = rt.Str("status")
|
||||
}
|
||||
cards, err := listAllTodoCards(rt, base, maxPages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matches := make([]map[string]any, 0)
|
||||
for _, card := range cards {
|
||||
subject, ok := card["subject"].(string)
|
||||
if !ok {
|
||||
return todoResponseError("todo/+search", "malformed_subject", "待办条目缺少字符串 subject")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(subject), query) {
|
||||
matches = append(matches, card)
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(matches), "todos": matches, "complete": true})
|
||||
},
|
||||
}
|
||||
|
||||
var Comment = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo", Command: "+comment", Product: "todo",
|
||||
Description: "添加待办评论并读回验证", Intent: "向指定待办添加评论;取得稳定 commentId 并从评论列表读回才报告成功。",
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("non_idempotent"),
|
||||
Contract: todoContract("+comment", "添加待办评论并读回验证", "已知 taskId 且需要发表明确评论时使用", todoObjectResult("已验证的新评论"), `dws todo +comment --task-id <TASK_ID> --content "已处理"`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "评论内容", Required: true},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
taskID, content := rt.Str("task-id"), rt.Str("content")
|
||||
if content == "" {
|
||||
return apperrors.NewValidation("--content 不能为空")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "taskId": taskID})
|
||||
}
|
||||
before, err := listAllTodoComments(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeIDs := make(map[string]bool, len(before))
|
||||
for _, comment := range before {
|
||||
beforeIDs[todoStableString(comment, "commentId", "id")] = true
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "add_todo_comment", map[string]any{"taskId": taskID, "content": content})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireTodoWriteReceipt(data, "todo/add_todo_comment"); err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := listAllTodoComments(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matches := make([]map[string]any, 0, 1)
|
||||
for _, comment := range after {
|
||||
id := todoStableString(comment, "commentId", "id")
|
||||
text, _ := comment["content"].(string)
|
||||
if id != "" && !beforeIDs[id] && text == content {
|
||||
matches = append(matches, comment)
|
||||
}
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
return todoResponseError("todo/add_todo_comment", "verification_ambiguous", "评论写入后无法在完整列表中唯一识别新增评论")
|
||||
}
|
||||
commentID := todoStableString(matches[0], "commentId", "id")
|
||||
return rt.Output(map[string]any{"taskId": taskID, "commentId": commentID, "content": content, "verified": true})
|
||||
},
|
||||
}
|
||||
|
||||
func listAllTodoComments(rt *shortcut.RuntimeContext, taskID string) ([]map[string]any, error) {
|
||||
all := make([]map[string]any, 0)
|
||||
for page := 1; page <= todoMaxPages; page++ {
|
||||
data, err := rt.CallMCPReadData("todo", "list_todo_comment", map[string]any{
|
||||
"taskId": taskID, "page": strconv.Itoa(page), "pageSize": strconv.Itoa(todoPageSize),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
comments, err := listCommentProjectStrict(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasMore, err := todoHasMore(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, comments...)
|
||||
if !hasMore {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
return nil, todoResponseError("todo/list_todo_comment", "pagination_limit_reached", "评论列表达到 40 页仍未耗尽,拒绝把不完整列表用于写后验证")
|
||||
}
|
||||
|
||||
var Reminder = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo", Command: "+reminder", Product: "todo",
|
||||
Description: "设置或清除待办提醒(仅终端回执)", Intent: "设置或清除提醒;上游无提醒读接口,结果固定 verified=false。",
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("unknown"),
|
||||
Contract: todoContract("+reminder", "设置或清除待办提醒(仅终端回执)", "接受无法读回核验且需要设置/清除提醒时使用", &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"提醒写入终端回执","properties":{"taskId":{"type":"string","description":"待办 taskId"},"action":{"type":"string","description":"set 或 clear"},"terminalReceipt":{"type":"boolean","description":"是否取得终端成功回执"},"verified":{"type":"boolean","description":"固定 false;上游无法读回提醒规则"}},"required":["taskId","action","terminalReceipt","verified"],"additionalProperties":false}`),
|
||||
}, `dws todo +reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30`),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||||
{Name: "clear", Type: shortcut.FlagBool, Desc: "清除全部提醒规则"},
|
||||
{Name: "base-time", Type: shortcut.FlagString, Enum: []string{"dueTime", "customTime"}, Desc: "提醒基准"},
|
||||
{Name: "due-date-offset", Type: shortcut.FlagInt, Desc: "相对截止时间的分钟偏移"},
|
||||
{Name: "at", Type: shortcut.FlagString, Desc: "customTime 的 ISO8601 时间"},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
taskID := rt.Str("task-id")
|
||||
clear := rt.Bool("clear")
|
||||
baseTime := rt.Str("base-time")
|
||||
if clear == (baseTime != "") {
|
||||
return apperrors.NewValidation("必须且只能选择 --clear 或 --base-time")
|
||||
}
|
||||
tool := "reset_todo_reminder"
|
||||
params := map[string]any{"todoReminderUpdateRequest": map[string]any{"taskId": taskID, "reminderRules": []any{}}}
|
||||
action := "clear"
|
||||
if !clear {
|
||||
request := map[string]any{"taskId": taskID, "baseTime": baseTime}
|
||||
if baseTime == "dueTime" {
|
||||
if !rt.Changed("due-date-offset") {
|
||||
return apperrors.NewValidation("--base-time=dueTime 要求 --due-date-offset")
|
||||
}
|
||||
request["dueDateOffset"] = strconv.Itoa(rt.Int("due-date-offset"))
|
||||
} else {
|
||||
if !rt.Changed("at") {
|
||||
return apperrors.NewValidation("--base-time=customTime 要求 --at")
|
||||
}
|
||||
millis, err := parseTodoMillis("at", rt.Str("at"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request["reminderTimeStamp"] = millis
|
||||
}
|
||||
tool = "add_todo_reminder"
|
||||
params = map[string]any{"todoReminderAddRequest": request}
|
||||
action = "set"
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"taskId": taskID, "action": action, "terminalReceipt": false, "verified": false, "dryRun": true})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireTodoWriteReceipt(data, "todo/"+tool); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "action": action, "terminalReceipt": true, "verified": false})
|
||||
},
|
||||
}
|
||||
|
||||
var UploadAttachment = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+upload-attachment",
|
||||
Product: "todo",
|
||||
Description: "上传待办附件(当前请使用原子命令)",
|
||||
Intent: "当前 Shortcut 运行时不能安全复用原子命令的带请求头上传事务,因此明确路由到 todo task add-attachment。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: todoWriteSafety("unknown"),
|
||||
Contract: todoContract(
|
||||
"+upload-attachment",
|
||||
"上传待办附件(当前请使用原子命令)",
|
||||
"需要上传本地文件为待办附件时,使用公开的原子命令完成完整上传事务",
|
||||
todoObjectResult("附件上传结果"),
|
||||
`dws todo +upload-attachment --task-id <TASK_ID> --file-path ./report.pdf`,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||||
{Name: "file-path", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
|
||||
},
|
||||
Execute: func(*shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("+upload-attachment 暂不复制原子上传事务;请使用 dws todo task add-attachment --task-id <TASK_ID> --file-path <PATH>")
|
||||
},
|
||||
}
|
||||
|
||||
func nonEmptyStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func init() {
|
||||
items := []*shortcut.Shortcut{&Create, &Update, &Complete, &Reopen, &Search, &Comment, &UploadAttachment, &Reminder}
|
||||
for _, item := range items {
|
||||
item.Contract.Selection.AgentSummary = item.Description
|
||||
item.Contract.Selection.UseWhen = []string{item.Intent}
|
||||
}
|
||||
shortcut.Register(Create, Update, Complete, Reopen, Search, Comment, UploadAttachment, Reminder)
|
||||
}
|
||||
+204
-45
@@ -22,6 +22,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -29,12 +30,13 @@ import (
|
||||
// CreateSub maps helper `create_personal_sub_todo`.
|
||||
// GetMyTasks maps helper `get_user_todos_in_current_org`.
|
||||
var GetMyTasks = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+get-my-tasks",
|
||||
Product: "todo",
|
||||
Description: "查询当前组织下我的待办列表",
|
||||
Intent: "当你想查看自己在当前组织下的待办清单、盘点未完成事项或按条件筛选任务时使用;可按完成状态、优先级、角色(创建者/执行者/参与者)和截止时间范围过滤并分页,返回匹配的待办列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+get-my-tasks",
|
||||
Product: "todo",
|
||||
Description: "查询当前组织下我的待办列表",
|
||||
Intent: "当你想查看自己在当前组织下的待办清单、盘点未完成事项或按条件筛选任务时使用;可按完成状态、优先级、角色(创建者/执行者/参与者)和截止时间范围过滤并分页,返回匹配的待办列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -48,6 +50,7 @@ var GetMyTasks = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +get-my-tasks",
|
||||
},
|
||||
Description: "查询当前组织下我的待办列表",
|
||||
Result: todoPagedCollectionResult("todos", "当前组织下我的待办列表"),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -68,12 +71,22 @@ var GetMyTasks = shortcut.Shortcut{
|
||||
{Name: "role-types", Type: shortcut.FlagStringSlice, Default: "executor", Desc: "角色类型: creator/executor/participant"},
|
||||
{Name: "plan-finish-start", Type: shortcut.FlagInt, Desc: "截止时间范围开始(Unix 毫秒时间戳)"},
|
||||
{Name: "plan-finish-end", Type: shortcut.FlagInt, Desc: "截止时间范围结束(Unix 毫秒时间戳)"},
|
||||
{Name: "all", Type: shortcut.FlagBool, Desc: "遍历全部分页;达到安全页数上限仍有下一页时失败"},
|
||||
{Name: "max-pages", Type: shortcut.FlagInt, Default: "40", Desc: "--all 的最大页数(1-40)"},
|
||||
},
|
||||
Tips: []string{`dws todo +get-my-tasks --status false --priority 40,30`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
page, err := strconv.Atoi(rt.Str("page"))
|
||||
if err != nil || page < 1 {
|
||||
return todoResponseError("todo/+get-my-tasks", "invalid_page", "--page 必须是大于 0 的整数")
|
||||
}
|
||||
size, err := strconv.Atoi(rt.Str("size"))
|
||||
if err != nil || size < 1 || size > 20 {
|
||||
return todoResponseError("todo/+get-my-tasks", "invalid_page_size", "--size 必须是 1 到 20 的整数;后端已知不支持更大值")
|
||||
}
|
||||
params := map[string]any{
|
||||
"pageNum": rt.Str("page"),
|
||||
"pageSize": rt.Str("size"),
|
||||
"pageNum": strconv.Itoa(page),
|
||||
"pageSize": strconv.Itoa(size),
|
||||
}
|
||||
if rt.Changed("status") {
|
||||
params["todoStatus"] = rt.Str("status")
|
||||
@@ -81,9 +94,11 @@ var GetMyTasks = shortcut.Shortcut{
|
||||
if rt.Changed("priority") {
|
||||
var ps []int
|
||||
for _, p := range rt.StrSlice("priority") {
|
||||
if n, err := strconv.Atoi(p); err == nil {
|
||||
ps = append(ps, n)
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || (n != 10 && n != 20 && n != 30 && n != 40) {
|
||||
return todoResponseError("todo/+get-my-tasks", "invalid_priority", "--priority 仅接受 10/20/30/40")
|
||||
}
|
||||
ps = append(ps, n)
|
||||
}
|
||||
if ps != nil {
|
||||
params["priorityList"] = ps
|
||||
@@ -94,21 +109,71 @@ var GetMyTasks = shortcut.Shortcut{
|
||||
roles = []string{"executor"}
|
||||
}
|
||||
params["roleTypes"] = roles
|
||||
for _, role := range roles {
|
||||
if role != "creator" && role != "executor" && role != "participant" {
|
||||
return todoResponseError("todo/+get-my-tasks", "invalid_role_type", "--role-types 仅接受 creator/executor/participant")
|
||||
}
|
||||
}
|
||||
if rt.Changed("plan-finish-start") {
|
||||
params["planFinishDateStart"] = rt.Int("plan-finish-start")
|
||||
}
|
||||
if rt.Changed("plan-finish-end") {
|
||||
params["planFinishDateEnd"] = rt.Int("plan-finish-end")
|
||||
}
|
||||
data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", params)
|
||||
if rt.Bool("all") {
|
||||
maxPages := rt.Int("max-pages")
|
||||
if maxPages < 1 || maxPages > todoMaxPages {
|
||||
return todoResponseError("todo/+get-my-tasks", "invalid_page_limit", "--max-pages 必须是 1 到 40")
|
||||
}
|
||||
delete(params, "pageNum")
|
||||
delete(params, "pageSize")
|
||||
cards, err := listAllTodoCards(rt, params, maxPages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(cards), "todos": cards, "complete": true})
|
||||
}
|
||||
data, err := rt.CallMCPReadData("todo", "get_user_todos_in_current_org", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cards := getMyTasksProject(data)
|
||||
return rt.Output(map[string]any{"count": len(cards), "todos": cards})
|
||||
cards, err := getMyTasksProjectStrict(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasMore, err := todoHasMore(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(cards), "todos": cards, "page": page, "size": size, "hasMore": hasMore})
|
||||
},
|
||||
}
|
||||
|
||||
func getMyTasksProjectStrict(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := requireTodoCollection(data, "todo/get_user_todos_in_current_org", "todoCards")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for i, m := range raw {
|
||||
if todoStableString(m, "taskId", "todoId", "id") == "" {
|
||||
return nil, todoResponseError("todo/get_user_todos_in_current_org", "missing_stable_id", "待办列表第 "+strconv.Itoa(i)+" 项缺少稳定 taskId")
|
||||
}
|
||||
out = append(out, projectTodoCard(m))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func projectTodoCard(m map[string]any) map[string]any {
|
||||
row := map[string]any{"taskId": todoStableString(m, "taskId", "todoId", "id")}
|
||||
for _, k := range []string{"subject", "dueTime", "priority", "finalStatusStage", "creatorId"} {
|
||||
if v, ok := m[k]; ok {
|
||||
row[k] = v
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// getMyTasksProject reshapes get_user_todos_in_current_org into a clean todo
|
||||
// list (subject/taskId/dueTime/priority/done) — clean output projection.
|
||||
// The card list lives under result.todoCards; fields are probed defensively.
|
||||
@@ -142,12 +207,13 @@ func getMyTasksProject(data map[string]any) []map[string]any {
|
||||
|
||||
// ListSub maps helper `list_sub_tasks`.
|
||||
var ListSub = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+list-sub",
|
||||
Product: "todo",
|
||||
Description: "查询子待办列表",
|
||||
Intent: "当你已知某个待办任务 ID、想了解它被拆解出的所有子任务时使用;输入父任务 ID,返回其下的子待办列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+list-sub",
|
||||
Product: "todo",
|
||||
Description: "查询子待办列表",
|
||||
Intent: "当你已知某个待办任务 ID、想了解它被拆解出的所有子任务时使用;输入父任务 ID,返回其下的子待办列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -161,6 +227,7 @@ var ListSub = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +list-sub",
|
||||
},
|
||||
Description: "查询子待办列表",
|
||||
Result: todoCollectionResult("subTasks", "指定父待办的子待办列表"),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -186,11 +253,37 @@ var ListSub = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subs := listSubProject(data)
|
||||
subs, err := listSubProjectStrict(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(subs), "subTasks": subs})
|
||||
},
|
||||
}
|
||||
|
||||
func listSubProjectStrict(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := requireTodoCollection(data, "todo/list_sub_tasks", "list", "items", "subTasks", "subTaskList")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for i, m := range raw {
|
||||
id := todoStableString(m, "taskId", "id", "subTaskId")
|
||||
if id == "" {
|
||||
return nil, todoResponseError("todo/list_sub_tasks", "missing_stable_id", "子待办列表第 "+strconv.Itoa(i)+" 项缺少稳定 taskId")
|
||||
}
|
||||
row := map[string]any{"taskId": id}
|
||||
if v := listSubFirst(m, "subject", "title", "name", "content"); v != nil {
|
||||
row["subject"] = v
|
||||
}
|
||||
if v := listSubFirst(m, "dueTime", "dueDate", "deadline"); v != nil {
|
||||
row["dueTime"] = v
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listSubProject reshapes list_sub_tasks into a clean sub-todo list
|
||||
// (subject/taskId/dueTime) — clean output projection. The list
|
||||
// container and field names are probed defensively across candidate keys.
|
||||
@@ -259,12 +352,13 @@ func listSubFirst(m map[string]any, keys ...string) any {
|
||||
// Complete maps helper `update_todo_done_status`.
|
||||
// Get maps helper `get_todo_detail`.
|
||||
var Get = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+get",
|
||||
Product: "todo",
|
||||
Description: "查询待办详情",
|
||||
Intent: "当你已知某条待办的任务 ID、想查看它的完整信息(标题、执行者、截止时间、优先级、状态等)时使用;输入任务 ID,返回该待办的详细内容。",
|
||||
Risk: shortcut.RiskRead,
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+get",
|
||||
Product: "todo",
|
||||
Description: "查询待办详情",
|
||||
Intent: "当你已知某条待办的任务 ID、想查看它的完整信息(标题、执行者、截止时间、优先级、状态等)时使用;输入任务 ID,返回该待办的详细内容。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -278,6 +372,7 @@ var Get = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +get",
|
||||
},
|
||||
Description: "查询待办详情",
|
||||
Result: todoObjectResult("待办详情"),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -295,9 +390,11 @@ var Get = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws todo +get --task-id <taskId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("get_todo_detail", map[string]any{
|
||||
"taskId": rt.Str("task-id"),
|
||||
})
|
||||
detail, err := readTodoDetail(rt, rt.Str("task-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(detail)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,12 +406,13 @@ var Get = shortcut.Shortcut{
|
||||
// AddReminder maps helper `add_todo_reminder`.
|
||||
// ListAttachment maps helper `list_todo_attachment`.
|
||||
var ListAttachment = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+list-attachment",
|
||||
Product: "todo",
|
||||
Description: "查询待办任务的附件列表",
|
||||
Intent: "当你想查看某条待办上挂了哪些附件、或需要拿到附件 ID 以便后续删除时使用;输入任务 ID,返回该待办的附件列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+list-attachment",
|
||||
Product: "todo",
|
||||
Description: "查询待办任务的附件列表",
|
||||
Intent: "当你想查看某条待办上挂了哪些附件、或需要拿到附件 ID 以便后续删除时使用;输入任务 ID,返回该待办的附件列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -328,6 +426,7 @@ var ListAttachment = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +list-attachment",
|
||||
},
|
||||
Description: "查询待办任务的附件列表",
|
||||
Result: todoCollectionResult("attachments", "待办附件列表"),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -353,11 +452,36 @@ var ListAttachment = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
atts := listAttachmentProject(data)
|
||||
atts, err := listAttachmentProjectStrict(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(atts), "attachments": atts})
|
||||
},
|
||||
}
|
||||
|
||||
func listAttachmentProjectStrict(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := requireTodoCollection(data, "todo/list_todo_attachment", "list", "items", "attachments", "attachmentList")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for i, m := range raw {
|
||||
id := todoStableString(m, "attachmentId", "id", "fileId", "dentryId")
|
||||
if id == "" {
|
||||
return nil, todoResponseError("todo/list_todo_attachment", "missing_stable_id", "附件列表第 "+strconv.Itoa(i)+" 项缺少稳定 ID")
|
||||
}
|
||||
row := map[string]any{"attachmentId": id}
|
||||
for _, pair := range [][2]string{{"fileName", "name"}, {"fileSize", "size"}, {"fileType", "type"}} {
|
||||
if v := listAttachmentFirst(m, pair[0], pair[1]); v != nil {
|
||||
row[pair[0]] = v
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listAttachmentProject reshapes list_todo_attachment into a clean attachment
|
||||
// list (attachmentId/fileName/fileSize/fileType) — output-projection fidelity
|
||||
// for clean output. The list container and field names are probed defensively across
|
||||
@@ -428,12 +552,13 @@ func listAttachmentFirst(m map[string]any, keys ...string) any {
|
||||
// AddComment maps helper `add_todo_comment`.
|
||||
// ListComment maps helper `list_todo_comment`.
|
||||
var ListComment = shortcut.Shortcut{
|
||||
Service: "todo",
|
||||
Command: "+list-comment",
|
||||
Product: "todo",
|
||||
Description: "查询待办评论列表",
|
||||
Intent: "当你想查看某条待办下的讨论记录、了解协作沟通历史或获取评论 ID 以便删除时使用;输入任务 ID 并可分页,返回该待办的评论列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo",
|
||||
Command: "+list-comment",
|
||||
Product: "todo",
|
||||
Description: "查询待办评论列表",
|
||||
Intent: "当你想查看某条待办下的讨论记录、了解协作沟通历史或获取评论 ID 以便删除时使用;输入任务 ID 并可分页,返回该待办的评论列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -447,6 +572,7 @@ var ListComment = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +list-comment",
|
||||
},
|
||||
Description: "查询待办评论列表",
|
||||
Result: todoCollectionResult("comments", "待办评论列表"),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
@@ -466,19 +592,52 @@ var ListComment = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws todo +list-comment --task-id <taskId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
page, err := strconv.Atoi(rt.Str("page"))
|
||||
if err != nil || page < 1 {
|
||||
return todoResponseError("todo/+list-comment", "invalid_page", "--page 必须是大于 0 的整数")
|
||||
}
|
||||
size, err := strconv.Atoi(rt.Str("size"))
|
||||
if err != nil || size < 1 || size > todoPageSize {
|
||||
return todoResponseError("todo/+list-comment", "invalid_page_size", "--size 必须是 1 到 20 的整数")
|
||||
}
|
||||
data, err := rt.CallMCPData("todo", "list_todo_comment", map[string]any{
|
||||
"taskId": rt.Str("task-id"),
|
||||
"page": rt.Str("page"),
|
||||
"pageSize": rt.Str("size"),
|
||||
"page": strconv.Itoa(page),
|
||||
"pageSize": strconv.Itoa(size),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
comments := listCommentProject(data)
|
||||
comments, err := listCommentProjectStrict(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(comments), "comments": comments})
|
||||
},
|
||||
}
|
||||
|
||||
func listCommentProjectStrict(data map[string]any) ([]map[string]any, error) {
|
||||
raw, err := requireTodoCollection(data, "todo/list_todo_comment", "list", "items", "comments", "commentList")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for i, m := range raw {
|
||||
id := todoStableString(m, "commentId", "id")
|
||||
if id == "" {
|
||||
return nil, todoResponseError("todo/list_todo_comment", "missing_stable_id", "评论列表第 "+strconv.Itoa(i)+" 项缺少稳定 commentId")
|
||||
}
|
||||
row := map[string]any{"commentId": id}
|
||||
for _, pair := range [][2]string{{"content", "text"}, {"creatorId", "userId"}, {"createTime", "createdTime"}} {
|
||||
if v := listCommentFirst(m, pair[0], pair[1]); v != nil {
|
||||
row[pair[0]] = v
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listCommentProject reshapes list_todo_comment into a clean comment list
|
||||
// (commentId/content/creatorId/createTime) — clean output projection.
|
||||
// The list container and field names are probed defensively across
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package todo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const todoCoverageTime = "2026-08-17T10:00:00+08:00"
|
||||
|
||||
type todoCoverageCaller struct {
|
||||
responses map[string][]string
|
||||
history []string
|
||||
arguments []map[string]any
|
||||
}
|
||||
|
||||
func (caller *todoCoverageCaller) 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 todo failure")
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
|
||||
}
|
||||
|
||||
func (caller *todoCoverageCaller) CallReadTool(ctx context.Context, product, tool string, arguments map[string]any) (*edition.ToolResult, error) {
|
||||
return caller.CallTool(ctx, product, tool, arguments)
|
||||
}
|
||||
|
||||
func (*todoCoverageCaller) Format() string { return "json" }
|
||||
func (*todoCoverageCaller) DryRun() bool { return false }
|
||||
func (*todoCoverageCaller) Fields() string { return "" }
|
||||
func (*todoCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runTodoCoverage(t *testing.T, declaration shortcut.Shortcut, caller *todoCoverageCaller, 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: "todo"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(append([]string{"todo", declaration.Command}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func todoRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
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 TestCrossPlatformCoverageTodoCommonStrictBranches(t *testing.T) {
|
||||
for _, response := range []map[string]any{{}, {"success": "yes"}, {"success": false}} {
|
||||
if _, err := requireTodoResponse(response, "todo/test"); err == nil {
|
||||
t.Fatalf("bad response accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
if _, err := requireTodoResponse(map[string]any{"result": map[string]any{}}, "todo/test"); err != nil {
|
||||
t.Fatalf("response without optional success rejected: %v", err)
|
||||
}
|
||||
for _, response := range []map[string]any{{}, {"result": map[string]any{}}, {"success": false}} {
|
||||
if err := requireTodoWriteReceipt(response, "todo/test"); err == nil {
|
||||
t.Fatalf("bad receipt accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
if err := requireTodoWriteReceipt(map[string]any{"success": true}, "todo/test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, response := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true, "result": map[string]any{"items": map[string]any{}}},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"items": []any{"bad"}}},
|
||||
} {
|
||||
if _, err := requireTodoCollection(response, "todo/test", "items"); err == nil {
|
||||
t.Fatalf("bad collection accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
items, err := requireTodoCollection(map[string]any{"success": true, "result": []any{map[string]any{"id": "x"}}}, "todo/test", "items")
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("direct wrapper collection=%#v %v", items, err)
|
||||
}
|
||||
|
||||
if _, err := findTodoHasMore(map[string]any{}, 5); err == nil {
|
||||
t.Fatal("deep pagination accepted")
|
||||
}
|
||||
for _, response := range []map[string]any{
|
||||
{"hasMore": "bad"},
|
||||
{"result": []any{}},
|
||||
{},
|
||||
} {
|
||||
if _, err := todoHasMore(response); err == nil {
|
||||
t.Fatalf("bad pagination accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
if more, err := todoHasMore(map[string]any{"result": map[string]any{"data": map[string]any{"hasMore": true}}}); err != nil || !more {
|
||||
t.Fatalf("nested pagination=%v %v", more, err)
|
||||
}
|
||||
|
||||
if raw, found, err := findTodoCollection(map[string]any{}, []string{"items"}, 5); err != nil || found || raw != nil {
|
||||
t.Fatalf("deep collection=%#v %v %v", raw, found, err)
|
||||
}
|
||||
for _, response := range []map[string]any{
|
||||
{"items": map[string]any{}},
|
||||
{"result": "bad"},
|
||||
} {
|
||||
if _, _, err := findTodoCollection(response, []string{"items"}, 0); err == nil {
|
||||
t.Fatalf("bad collection wrapper accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
if raw, found, err := findTodoCollection(map[string]any{"data": []any{}}, []string{"items"}, 0); err != nil || !found || raw == nil {
|
||||
t.Fatalf("direct array wrapper=%#v %v %v", raw, found, err)
|
||||
}
|
||||
|
||||
for _, response := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true, "result": map[string]any{"todoDetailModel": []any{}}},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"todoDetailModel": map[string]any{"subject": "no id"}}},
|
||||
} {
|
||||
if _, err := requireTodoDetail(response, "todo/test", ""); err == nil {
|
||||
t.Fatalf("bad detail accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
for _, response := range []map[string]any{
|
||||
{"success": true, "result": map[string]any{"taskId": "task-1"}},
|
||||
{"success": true, "taskId": "task-1"},
|
||||
} {
|
||||
if detail, err := requireTodoDetail(response, "todo/test", "task-1"); err != nil || todoStableString(detail, "taskId") != "task-1" {
|
||||
t.Fatalf("fallback detail=%#v %v", detail, err)
|
||||
}
|
||||
}
|
||||
if object, found, err := findTodoObject(map[string]any{}, []string{"todo"}, 5); err != nil || found || object != nil {
|
||||
t.Fatalf("deep object=%#v %v %v", object, found, err)
|
||||
}
|
||||
for _, response := range []map[string]any{{"todo": []any{}}, {"result": []any{}}} {
|
||||
if _, _, err := findTodoObject(response, []string{"todo"}, 0); err == nil {
|
||||
t.Fatalf("bad object wrapper accepted: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
if got := findTodoStableString(map[string]any{}, []string{"id"}, 6); got != "" {
|
||||
t.Fatalf("deep stable string=%q", got)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{" id ", "id"},
|
||||
{json.Number("12"), "12"},
|
||||
{float64(13), "13"},
|
||||
{float64(13.5), ""},
|
||||
{14, "14"},
|
||||
{int64(15), "15"},
|
||||
} {
|
||||
if got := todoStableString(map[string]any{"id": tc.value}, "id"); got != tc.want {
|
||||
t.Fatalf("stable %#v=%q want %q", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := findTodoStableString(map[string]any{"result": map[string]any{"data": map[string]any{"id": "nested"}}}, []string{"id"}, 0); got != "nested" {
|
||||
t.Fatalf("nested stable string=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoProjectionBranches(t *testing.T) {
|
||||
for name, project := range map[string]func(map[string]any) error{
|
||||
"sub": func(data map[string]any) error { _, err := listSubProjectStrict(data); return err },
|
||||
"attachment": func(data map[string]any) error { _, err := listAttachmentProjectStrict(data); return err },
|
||||
"comment": func(data map[string]any) error { _, err := listCommentProjectStrict(data); return err },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := project(map[string]any{"success": true, "result": map[string]any{"list": []any{map[string]any{"name": "missing id"}}, "hasMore": false}}); err == nil {
|
||||
t.Fatal("item without stable id accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
subs, err := listSubProjectStrict(map[string]any{"success": true, "result": map[string]any{"list": []any{map[string]any{"id": "sub-1", "title": "title", "deadline": 1}}}})
|
||||
if err != nil || len(subs) != 1 || subs[0]["subject"] != "title" || subs[0]["dueTime"] != 1 {
|
||||
t.Fatalf("sub projection=%#v %v", subs, err)
|
||||
}
|
||||
attachments, err := listAttachmentProjectStrict(map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"id": "a-1", "name": "file", "size": 1, "type": "text"}}}})
|
||||
if err != nil || len(attachments) != 1 {
|
||||
t.Fatalf("attachment projection=%#v %v", attachments, err)
|
||||
}
|
||||
comments, err := listCommentProjectStrict(map[string]any{"success": true, "result": map[string]any{"comments": []any{map[string]any{"id": "c-1", "text": "body", "userId": "u", "createdTime": 1}}}})
|
||||
if err != nil || len(comments) != 1 {
|
||||
t.Fatalf("comment projection=%#v %v", comments, err)
|
||||
}
|
||||
|
||||
_ = listSubProject(map[string]any{"result": []any{"bad", map[string]any{}, map[string]any{"title": "x", "id": "s", "deadline": 1}}})
|
||||
_ = listAttachmentProject(map[string]any{"data": []any{"bad", map[string]any{}, map[string]any{"id": "a", "name": "n", "size": 1, "type": "t"}}})
|
||||
_ = listCommentProject(map[string]any{"result": []any{"bad", map[string]any{}, map[string]any{"id": "c", "text": "x", "creator": "u", "gmtCreate": 1}}})
|
||||
if listSubFirst(map[string]any{}, "x") != nil || listAttachmentFirst(map[string]any{}, "x") != nil || listCommentFirst(map[string]any{}, "x") != nil {
|
||||
t.Fatal("missing projection fields were invented")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoLifecycleCore(t *testing.T) {
|
||||
if _, err := parseTodoMillis("due", "bad"); err == nil {
|
||||
t.Fatal("bad due time accepted")
|
||||
}
|
||||
if millis, err := parseTodoMillis("due", todoCoverageTime); err != nil || millis == 0 {
|
||||
t.Fatalf("due millis=%d %v", millis, err)
|
||||
}
|
||||
if got := nonEmptyStrings([]string{" a ", "", " b "}); strings.Join(got, ",") != "a,b" {
|
||||
t.Fatalf("non-empty strings=%#v", got)
|
||||
}
|
||||
if err := runTodoCoverage(t, UploadAttachment, &todoCoverageCaller{responses: map[string][]string{}}, "--task-id", "t", "--file-path", "file", "--yes"); err == nil {
|
||||
t.Fatal("unavailable upload shortcut executed")
|
||||
}
|
||||
if got := strconv.Itoa(todoPageSize); got != "20" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) {
|
||||
for name, values := range map[string]map[string]string{
|
||||
"create-required": {"title": "", "executors": ""},
|
||||
"create-due": {"title": "x", "executors": "u", "due": "bad"},
|
||||
"create-priority": {"title": "x", "executors": "u", "priority": "11"},
|
||||
"update-none": {"task-id": "task-1"},
|
||||
"update-title": {"task-id": "task-1", "title": ""},
|
||||
"update-due": {"task-id": "task-1", "due": "bad"},
|
||||
"update-priority": {"task-id": "task-1", "priority": "11"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
declaration, execute := Create, createTodo
|
||||
if strings.HasPrefix(name, "update-") {
|
||||
declaration, execute = Update, updateTodo
|
||||
}
|
||||
if err := execute(todoRuntimeForTest(t, declaration, values)); err == nil {
|
||||
t.Fatal("invalid lifecycle values accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := runTodoCoverage(t, Create, &todoCoverageCaller{responses: map[string][]string{}}, "--title", "x", "--executors", "u", "--due", todoCoverageTime, "--priority", "40", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatalf("create dry-run: %v", err)
|
||||
}
|
||||
if err := runTodoCoverage(t, Update, &todoCoverageCaller{responses: map[string][]string{}}, "--task-id", "task-1", "--title", "x", "--due", todoCoverageTime, "--priority", "40", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatalf("update dry-run: %v", err)
|
||||
}
|
||||
|
||||
createCases := map[string]map[string][]string{
|
||||
"call": {"create_personal_todo": {"__ERROR__"}},
|
||||
"receipt": {"create_personal_todo": {`{"result":{}}`}},
|
||||
"id": {"create_personal_todo": {`{"success":true,"result":{}}`}},
|
||||
"read-call": {"create_personal_todo": {`{"success":true,"result":{"taskId":"task-1"}}`}, "get_todo_detail": {"__ERROR__"}},
|
||||
"read-shape": {"create_personal_todo": {`{"success":true,"result":{"taskId":"task-1"}}`}, "get_todo_detail": {`{"success":true,"result":{}}`}},
|
||||
"mismatch": {"create_personal_todo": {`{"success":true,"result":{"taskId":"task-1"}}`}, "get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"wrong"}}}`}},
|
||||
}
|
||||
for name, responses := range createCases {
|
||||
t.Run("create-"+name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Create, &todoCoverageCaller{responses: responses}, "--title", "x", "--executors", "u", "--yes"); err == nil {
|
||||
t.Fatal("bad create path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
createSuccess := &todoCoverageCaller{responses: map[string][]string{
|
||||
"create_personal_todo": {`{"success":true,"result":{"taskId":"task-1"}}`},
|
||||
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x"}}}`},
|
||||
}}
|
||||
if err := runTodoCoverage(t, Create, createSuccess, "--title", "x", "--executors", "u", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updateCases := map[string]map[string][]string{
|
||||
"call": {"update_todo_task": {"__ERROR__"}},
|
||||
"receipt": {"update_todo_task": {`{"result":{}}`}},
|
||||
"read-call": {"update_todo_task": {`{"success":true}`}, "get_todo_detail": {"__ERROR__"}},
|
||||
"read-shape": {"update_todo_task": {`{"success":true}`}, "get_todo_detail": {`{"success":true,"result":{}}`}},
|
||||
"mismatch": {"update_todo_task": {`{"success":true}`}, "get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"wrong"}}}`}},
|
||||
}
|
||||
for name, responses := range updateCases {
|
||||
t.Run("update-"+name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Update, &todoCoverageCaller{responses: responses}, "--task-id", "task-1", "--title", "x", "--yes"); err == nil {
|
||||
t.Fatal("bad update path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
updateSuccess := &todoCoverageCaller{responses: map[string][]string{
|
||||
"update_todo_task": {`{"success":true}`},
|
||||
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40}}}`},
|
||||
}}
|
||||
if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoDoneBranches(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Complete, &todoCoverageCaller{responses: map[string][]string{}}, "--task-id", "task-1", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, responses := range map[string]map[string][]string{
|
||||
"before-call": {"get_todo_detail": {"__ERROR__"}},
|
||||
"before-shape": {"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1"}}}`}},
|
||||
"write-call": {"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":false}}}`}, "update_todo_done_status": {"__ERROR__"}},
|
||||
"receipt": {"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":false}}}`}, "update_todo_done_status": {`{"result":{}}`}},
|
||||
"read-call": {"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":false}}}`, "__ERROR__"}, "update_todo_done_status": {`{"success":true}`}},
|
||||
"read-mismatch": {"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":false}}}`, `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":false}}}`}, "update_todo_done_status": {`{"success":true}`}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Complete, &todoCoverageCaller{responses: responses}, "--task-id", "task-1", "--yes"); err == nil {
|
||||
t.Fatal("bad done path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
already := &todoCoverageCaller{responses: map[string][]string{"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":true}}}`}}}
|
||||
if err := runTodoCoverage(t, Complete, already, "--task-id", "task-1", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
success := &todoCoverageCaller{responses: map[string][]string{
|
||||
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":false}}}`, `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":true}}}`},
|
||||
"update_todo_done_status": {`{"success":true}`},
|
||||
}}
|
||||
if err := runTodoCoverage(t, Complete, success, "--task-id", "task-1", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopen := doneShortcut("+reopen-test", false); !strings.Contains(reopen.Description, "重新") {
|
||||
t.Fatalf("reopen description=%q", reopen.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoSearchAndPagination(t *testing.T) {
|
||||
searchNoQuery := Search
|
||||
for index := range searchNoQuery.Flags {
|
||||
if searchNoQuery.Flags[index].Name == "query" {
|
||||
searchNoQuery.Flags[index].Required = false
|
||||
}
|
||||
}
|
||||
if err := Search.Execute(todoRuntimeForTest(t, searchNoQuery, map[string]string{})); err == nil {
|
||||
t.Fatal("empty search accepted")
|
||||
}
|
||||
if err := runTodoCoverage(t, Search, &todoCoverageCaller{responses: map[string][]string{}}, "--query", "x", "--max-pages", "0"); err == nil {
|
||||
t.Fatal("bad max-pages accepted")
|
||||
}
|
||||
for name, response := range map[string]string{
|
||||
"call": "__ERROR__",
|
||||
"collection": `{"success":true,"result":{"hasMore":false}}`,
|
||||
"pagination": `{"success":true,"result":{"todoCards":[]}}`,
|
||||
"subject": `{"success":true,"result":{"todoCards":[{"taskId":"task-1"}],"hasMore":false}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &todoCoverageCaller{responses: map[string][]string{"get_user_todos_in_current_org": {response}}}
|
||||
if err := runTodoCoverage(t, Search, caller, "--query", "x", "--status", "false", "--max-pages", "1"); err == nil {
|
||||
t.Fatal("bad search page accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
limit := &todoCoverageCaller{responses: map[string][]string{"get_user_todos_in_current_org": {`{"success":true,"result":{"todoCards":[],"hasMore":true}}`}}}
|
||||
if err := runTodoCoverage(t, Search, limit, "--query", "x", "--max-pages", "1"); err == nil {
|
||||
t.Fatal("incomplete search accepted")
|
||||
}
|
||||
success := &todoCoverageCaller{responses: map[string][]string{"get_user_todos_in_current_org": {
|
||||
`{"success":true,"result":{"todoCards":[{"taskId":"task-1","subject":"Needle"}],"hasMore":true}}`,
|
||||
`{"success":true,"result":{"todoCards":[{"taskId":"task-2","subject":"other"}],"hasMore":false}}`,
|
||||
}}}
|
||||
if err := runTodoCoverage(t, Search, success, "--query", "needle", "--max-pages", "2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoCommentAndReminder(t *testing.T) {
|
||||
commentNoContent := Comment
|
||||
for index := range commentNoContent.Flags {
|
||||
if commentNoContent.Flags[index].Name == "content" {
|
||||
commentNoContent.Flags[index].Required = false
|
||||
}
|
||||
}
|
||||
if err := Comment.Execute(todoRuntimeForTest(t, commentNoContent, map[string]string{"task-id": "task-1"})); err == nil {
|
||||
t.Fatal("empty comment accepted")
|
||||
}
|
||||
if err := runTodoCoverage(t, Comment, &todoCoverageCaller{responses: map[string][]string{}}, "--task-id", "task-1", "--content", "body", "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emptyPage := `{"success":true,"result":{"comments":[],"hasMore":false}}`
|
||||
for name, responses := range map[string]map[string][]string{
|
||||
"before-call": {"list_todo_comment": {"__ERROR__"}},
|
||||
"before-shape": {"list_todo_comment": {`{"success":true,"result":{}}`}},
|
||||
"before-pagination": {"list_todo_comment": {`{"success":true,"result":{"comments":[]}}`}},
|
||||
"write-call": {"list_todo_comment": {emptyPage}, "add_todo_comment": {"__ERROR__"}},
|
||||
"receipt": {"list_todo_comment": {emptyPage}, "add_todo_comment": {`{"result":{}}`}},
|
||||
"after-call": {"list_todo_comment": {emptyPage, "__ERROR__"}, "add_todo_comment": {`{"success":true}`}},
|
||||
"ambiguous": {"list_todo_comment": {emptyPage, emptyPage}, "add_todo_comment": {`{"success":true}`}},
|
||||
} {
|
||||
t.Run("comment-"+name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Comment, &todoCoverageCaller{responses: responses}, "--task-id", "task-1", "--content", "body", "--yes"); err == nil {
|
||||
t.Fatal("bad comment path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
commentSuccess := &todoCoverageCaller{responses: map[string][]string{
|
||||
"list_todo_comment": {`{"success":true,"result":{"comments":[{"commentId":"old","content":"old"}],"hasMore":false}}`, `{"success":true,"result":{"comments":[{"commentId":"old","content":"old"},{"commentId":"new","content":"body"}],"hasMore":false}}`},
|
||||
"add_todo_comment": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
if err := runTodoCoverage(t, Comment, commentSuccess, "--task-id", "task-1", "--content", "body", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, args := range map[string][]string{
|
||||
"none": {"--task-id", "task-1", "--yes"},
|
||||
"both": {"--task-id", "task-1", "--clear", "--base-time", "dueTime", "--yes"},
|
||||
"due-offset": {"--task-id", "task-1", "--base-time", "dueTime", "--yes"},
|
||||
"custom-at": {"--task-id", "task-1", "--base-time", "customTime", "--yes"},
|
||||
"custom-value": {"--task-id", "task-1", "--base-time", "customTime", "--at", "bad", "--yes"},
|
||||
} {
|
||||
t.Run("reminder-"+name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Reminder, &todoCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
|
||||
t.Fatal("bad reminder arguments accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, args := range map[string][]string{
|
||||
"clear": {"--task-id", "task-1", "--clear", "--dry-run", "--yes"},
|
||||
"due": {"--task-id", "task-1", "--base-time", "dueTime", "--due-date-offset", "-30", "--dry-run", "--yes"},
|
||||
"at": {"--task-id", "task-1", "--base-time", "customTime", "--at", todoCoverageTime, "--dry-run", "--yes"},
|
||||
} {
|
||||
t.Run("reminder-dry-"+name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Reminder, &todoCoverageCaller{responses: map[string][]string{}}, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tool string
|
||||
args []string
|
||||
}{
|
||||
{"clear", "reset_todo_reminder", []string{"--task-id", "task-1", "--clear", "--yes"}},
|
||||
{"due", "add_todo_reminder", []string{"--task-id", "task-1", "--base-time", "dueTime", "--due-date-offset", "-30", "--yes"}},
|
||||
{"at", "add_todo_reminder", []string{"--task-id", "task-1", "--base-time", "customTime", "--at", todoCoverageTime, "--yes"}},
|
||||
} {
|
||||
t.Run("reminder-success-"+tc.name, func(t *testing.T) {
|
||||
caller := &todoCoverageCaller{responses: map[string][]string{tc.tool: {`{"success":true}`}}}
|
||||
if err := runTodoCoverage(t, Reminder, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, response := range map[string]string{"call": "__ERROR__", "receipt": `{"result":{}}`} {
|
||||
t.Run("reminder-remote-"+name, func(t *testing.T) {
|
||||
caller := &todoCoverageCaller{responses: map[string][]string{"reset_todo_reminder": {response}}}
|
||||
if err := runTodoCoverage(t, Reminder, caller, "--task-id", "task-1", "--clear", "--yes"); err == nil {
|
||||
t.Fatal("bad reminder response accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
pages := make([]string, todoMaxPages)
|
||||
for index := range pages {
|
||||
pages[index] = `{"success":true,"result":{"comments":[],"hasMore":true}}`
|
||||
}
|
||||
capCaller := &todoCoverageCaller{responses: map[string][]string{"list_todo_comment": pages}}
|
||||
helpers.InitDepsForTest(t, capCaller)
|
||||
if _, err := listAllTodoComments(todoRuntimeForTest(t, Comment, map[string]string{"task-id": "task-1", "content": "x"}), "task-1"); err == nil {
|
||||
t.Fatal("comment pagination cap accepted incomplete data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoVerificationHelpers(t *testing.T) {
|
||||
type createdCase struct {
|
||||
name string
|
||||
response map[string]any
|
||||
read string
|
||||
expected string
|
||||
wantError bool
|
||||
}
|
||||
for _, tc := range []createdCase{
|
||||
{"receipt", map[string]any{}, "", "x", true},
|
||||
{"id", map[string]any{"success": true}, "", "x", true},
|
||||
{"read", map[string]any{"success": true, "taskId": "task-1"}, "__ERROR__", "x", true},
|
||||
{"mismatch", map[string]any{"success": true, "taskId": "task-1"}, `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"wrong"}}}`, "x", true},
|
||||
{"success", map[string]any{"success": true, "taskId": "task-1"}, `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x"}}}`, "x", false},
|
||||
} {
|
||||
t.Run("created-"+tc.name, func(t *testing.T) {
|
||||
caller := &todoCoverageCaller{responses: map[string][]string{}}
|
||||
if tc.read != "" {
|
||||
caller.responses["get_todo_detail"] = []string{tc.read}
|
||||
}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
_, _, err := VerifyCreatedTodo(todoRuntimeForTest(t, Get, map[string]string{"task-id": "task-1"}), tc.response, "todo/create", tc.expected)
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("error=%v wantError=%v", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, fixture := range map[string]struct {
|
||||
response map[string]any
|
||||
read string
|
||||
wantErr bool
|
||||
}{
|
||||
"receipt": {map[string]any{}, "", true},
|
||||
"read": {map[string]any{"success": true}, "__ERROR__", true},
|
||||
"mismatch": {map[string]any{"success": true}, `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":false}}}`, true},
|
||||
"success": {map[string]any{"success": true}, `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","isDone":true}}}`, false},
|
||||
} {
|
||||
t.Run("done-"+name, func(t *testing.T) {
|
||||
caller := &todoCoverageCaller{responses: map[string][]string{}}
|
||||
if fixture.read != "" {
|
||||
caller.responses["get_todo_detail"] = []string{fixture.read}
|
||||
}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
err := VerifyDoneStatus(todoRuntimeForTest(t, Get, map[string]string{"task-id": "task-1"}), fixture.response, "task-1", true)
|
||||
if (err != nil) != fixture.wantErr {
|
||||
t.Fatalf("error=%v wantError=%v", err, fixture.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoGetMyTasksAndListLeaves(t *testing.T) {
|
||||
for name, args := range map[string][]string{
|
||||
"page": {"--page", "0"},
|
||||
"size": {"--size", "21"},
|
||||
"priority": {"--priority", "11"},
|
||||
"role": {"--role-types", "owner"},
|
||||
"max": {"--all", "--max-pages", "0"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, GetMyTasks, &todoCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
|
||||
t.Fatal("bad list arguments accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, response := range map[string]string{
|
||||
"call": "__ERROR__",
|
||||
"collection": `{"success":true,"result":{"hasMore":false}}`,
|
||||
"pagination": `{"success":true,"result":{"todoCards":[]}}`,
|
||||
} {
|
||||
t.Run("list-"+name, func(t *testing.T) {
|
||||
caller := &todoCoverageCaller{responses: map[string][]string{"get_user_todos_in_current_org": {response}}}
|
||||
if err := runTodoCoverage(t, GetMyTasks, caller); err == nil {
|
||||
t.Fatal("bad list response accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
normal := &todoCoverageCaller{responses: map[string][]string{"get_user_todos_in_current_org": {`{"success":true,"result":{"todoCards":[{"taskId":"task-1","subject":"x"}],"hasMore":true}}`}}}
|
||||
if err := runTodoCoverage(t, GetMyTasks, normal, "--status", "false", "--priority", "40,30", "--role-types", "creator,participant", "--plan-finish-start", "1", "--plan-finish-end", "2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allFailure := &todoCoverageCaller{responses: map[string][]string{"get_user_todos_in_current_org": {"__ERROR__"}}}
|
||||
if err := runTodoCoverage(t, GetMyTasks, allFailure, "--all", "--max-pages", "1"); err == nil {
|
||||
t.Fatal("all-pages transport failure accepted")
|
||||
}
|
||||
allSuccess := &todoCoverageCaller{responses: map[string][]string{"get_user_todos_in_current_org": {`{"success":true,"result":{"todoCards":[],"hasMore":false}}`}}}
|
||||
if err := runTodoCoverage(t, GetMyTasks, allSuccess, "--all", "--max-pages", "1", "--role-types", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
getFailure := &todoCoverageCaller{responses: map[string][]string{"get_todo_detail": {"__ERROR__"}}}
|
||||
if err := runTodoCoverage(t, Get, getFailure, "--task-id", "task-1"); err == nil {
|
||||
t.Fatal("get transport failure accepted")
|
||||
}
|
||||
getSuccess := &todoCoverageCaller{responses: map[string][]string{"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1"}}}`}}}
|
||||
if err := runTodoCoverage(t, Get, getSuccess, "--task-id", "task-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, args := range map[string][]string{"page": {"--task-id", "task-1", "--page", "0"}, "size": {"--task-id", "task-1", "--size", "21"}} {
|
||||
t.Run("comments-"+name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, ListComment, &todoCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
|
||||
t.Fatal("bad comment list args accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, response := range map[string]string{"call": "__ERROR__", "shape": `{"success":true,"result":{}}`} {
|
||||
t.Run("comments-"+name, func(t *testing.T) {
|
||||
caller := &todoCoverageCaller{responses: map[string][]string{"list_todo_comment": {response}}}
|
||||
if err := runTodoCoverage(t, ListComment, caller, "--task-id", "task-1"); err == nil {
|
||||
t.Fatal("bad comment list response accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
comments := &todoCoverageCaller{responses: map[string][]string{"list_todo_comment": {`{"success":true,"result":{"comments":[{"commentId":"c-1","content":"x"}]}}`}}}
|
||||
if err := runTodoCoverage(t, ListComment, comments, "--task-id", "task-1", "--page", "2", "--size", "10"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package todo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageTodoStrictCollectionsDistinguishEmptyFromMalformed(t *testing.T) {
|
||||
empty := map[string]any{"success": true, "result": map[string]any{"todoCards": []any{}, "hasMore": false, "page": float64(1), "size": float64(20)}}
|
||||
items, err := getMyTasksProjectStrict(empty)
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("explicit empty todoCards = %#v, %v; want legal empty", items, err)
|
||||
}
|
||||
if _, err := getMyTasksProjectStrict(map[string]any{"success": true, "result": map[string]any{"hasMore": false}}); err == nil || !strings.Contains(err.Error(), "列表容器") {
|
||||
t.Fatalf("missing collection error = %v", err)
|
||||
}
|
||||
if _, err := getMyTasksProjectStrict(map[string]any{"success": true, "result": map[string]any{"todoCards": []any{"bad"}, "hasMore": false}}); err == nil || !strings.Contains(err.Error(), "不是对象") {
|
||||
t.Fatalf("malformed item error = %v", err)
|
||||
}
|
||||
if _, err := getMyTasksProjectStrict(map[string]any{"success": true, "result": map[string]any{"todoCards": []any{map[string]any{"subject": "missing id"}}, "hasMore": false}}); err == nil || !strings.Contains(err.Error(), "taskId") {
|
||||
t.Fatalf("missing taskId error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoDetailBindsRequestedTaskID(t *testing.T) {
|
||||
response := map[string]any{"success": true, "result": map[string]any{"todoDetailModel": map[string]any{"taskId": "task-a", "subject": "A"}}}
|
||||
detail, err := requireTodoDetail(response, "todo/get_todo_detail", "task-a")
|
||||
if err != nil || detail["subject"] != "A" {
|
||||
t.Fatalf("detail = %#v, %v", detail, err)
|
||||
}
|
||||
if _, err := requireTodoDetail(response, "todo/get_todo_detail", "task-b"); err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("identity mismatch error = %v", err)
|
||||
}
|
||||
if _, err := requireTodoDetail(map[string]any{"success": true, "result": map[string]any{}}, "todo/get_todo_detail", "task-a"); err == nil {
|
||||
t.Fatal("missing todoDetailModel unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsTodoLifecycleContractsAreComplete(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
result bool
|
||||
safety string
|
||||
}{
|
||||
{"create", Create.OutputRollout, Create.Contract.Result != nil, Create.Safety.Confirmation},
|
||||
{"update", Update.OutputRollout, Update.Contract.Result != nil, Update.Safety.Confirmation},
|
||||
{"complete", Complete.OutputRollout, Complete.Contract.Result != nil, Complete.Safety.Confirmation},
|
||||
{"reopen", Reopen.OutputRollout, Reopen.Contract.Result != nil, Reopen.Safety.Confirmation},
|
||||
{"search", Search.OutputRollout, Search.Contract.Result != nil, Search.Safety.Confirmation},
|
||||
{"comment", Comment.OutputRollout, Comment.Contract.Result != nil, Comment.Safety.Confirmation},
|
||||
{"upload-attachment", UploadAttachment.OutputRollout, UploadAttachment.Contract.Result != nil, UploadAttachment.Safety.Confirmation},
|
||||
{"reminder", Reminder.OutputRollout, Reminder.Contract.Result != nil, Reminder.Safety.Confirmation},
|
||||
{"get-my-tasks", GetMyTasks.OutputRollout, GetMyTasks.Contract.Result != nil, GetMyTasks.Safety.Confirmation},
|
||||
{"list-sub", ListSub.OutputRollout, ListSub.Contract.Result != nil, ListSub.Safety.Confirmation},
|
||||
{"get", Get.OutputRollout, Get.Contract.Result != nil, Get.Safety.Confirmation},
|
||||
{"list-attachment", ListAttachment.OutputRollout, ListAttachment.Contract.Result != nil, ListAttachment.Safety.Confirmation},
|
||||
{"list-comment", ListComment.OutputRollout, ListComment.Contract.Result != nil, ListComment.Safety.Confirmation},
|
||||
} {
|
||||
if item.rollout != output.RolloutUnifiedActive || !item.result || item.safety == "" {
|
||||
t.Errorf("%s contract incomplete: rollout=%q result=%v safety=%q", item.name, item.rollout, item.result, item.safety)
|
||||
}
|
||||
}
|
||||
if text := string(Reminder.Contract.Result.DataSchema); !strings.Contains(text, `"verified"`) || !strings.Contains(Reminder.Intent, "verified=false") {
|
||||
t.Fatalf("reminder must publish terminal-only verification boundary: %s / %s", text, Reminder.Intent)
|
||||
}
|
||||
}
|
||||
Executable
+659
@@ -0,0 +1,659 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PII-safe live E2E for the public Calendar and Todo shortcut surfaces.
|
||||
|
||||
Raw command responses are kept in memory. The script prints only stable
|
||||
capability labels and reason codes; it never prints identities, resource IDs,
|
||||
titles, room names, response bodies, traces, or local paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BIN = Path(os.environ.get("DWS_E2E_BIN", ROOT / "dws"))
|
||||
|
||||
CALENDAR_CASES = {
|
||||
"+agenda", "+attendee-list", "+book", "+book-list", "+book-search",
|
||||
"+cancel-event", "+conflicts", "+create", "+free", "+free-slots",
|
||||
"+freebusy", "+get", "+invite", "+my-free", "+next-event",
|
||||
"+reschedule", "+room-find", "+room-groups", "+room-search", "+rsvp",
|
||||
"+search-event", "+suggest-time", "+suggestion", "+today", "+tomorrow",
|
||||
"+update", "+week",
|
||||
}
|
||||
TODO_CASES = {
|
||||
"+assign", "+assign-multi", "+comment", "+complete", "+create",
|
||||
"+created-todos", "+due-today", "+get", "+get-my-tasks",
|
||||
"+get-related-tasks", "+list-attachment", "+list-comment", "+list-sub",
|
||||
"+overdue", "+remind", "+reminder", "+reopen", "+search",
|
||||
"+todo-done", "+update",
|
||||
}
|
||||
|
||||
|
||||
class E2EError(RuntimeError):
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
results: dict[str, tuple[str, str]] = {}
|
||||
|
||||
|
||||
def record(product: str, command: str, status: str, reason: str = "") -> None:
|
||||
results[f"{product}/{command}"] = (status, reason)
|
||||
|
||||
|
||||
def run_case(product: str, command: str, fn: Callable[[], None], *, blocked_ok: bool = False) -> None:
|
||||
try:
|
||||
fn()
|
||||
except E2EError as exc:
|
||||
record(product, command, "BLOCKED" if blocked_ok else "FAIL", exc.reason)
|
||||
except Exception:
|
||||
record(product, command, "FAIL", "unexpected_exception")
|
||||
else:
|
||||
record(product, command, "PASS")
|
||||
|
||||
|
||||
def invoke(args: list[str], *, confirm: bool = False, expect_success: bool = True) -> Any:
|
||||
command = [str(BIN), *args]
|
||||
if "--format" not in command:
|
||||
command.extend(["--format", "json"])
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
command,
|
||||
cwd=ROOT,
|
||||
input="yes\n" if confirm else "",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=90,
|
||||
env={**os.environ, "NO_COLOR": "1"},
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise E2EError("command_timeout") from exc
|
||||
if expect_success and proc.returncode != 0:
|
||||
failure = None
|
||||
for raw in (proc.stdout, proc.stderr):
|
||||
try:
|
||||
candidate = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(candidate, dict) and isinstance(candidate.get("error"), dict):
|
||||
failure = candidate["error"]
|
||||
break
|
||||
if failure:
|
||||
code = failure.get("upstream_code") or failure.get("server_error_code")
|
||||
if isinstance(code, (str, int)) and str(code).isalnum():
|
||||
raise E2EError("upstream_" + str(code))
|
||||
subtype = failure.get("subtype") or failure.get("reason")
|
||||
if isinstance(subtype, str) and subtype.replace("_", "").isalnum():
|
||||
raise E2EError(subtype)
|
||||
raise E2EError("nonzero_exit")
|
||||
if not expect_success:
|
||||
if proc.returncode == 0:
|
||||
raise E2EError("unexpected_zero_exit")
|
||||
return None
|
||||
if not proc.stdout.strip():
|
||||
raise E2EError("empty_stdout")
|
||||
try:
|
||||
payload = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E2EError("malformed_json") from exc
|
||||
if isinstance(payload, dict) and "ok" in payload:
|
||||
if payload.get("ok") is not True or payload.get("outcome") != "success":
|
||||
raise E2EError("non_success_envelope")
|
||||
if "data" not in payload:
|
||||
raise E2EError("missing_data")
|
||||
return payload
|
||||
|
||||
|
||||
def business(payload: Any) -> Any:
|
||||
if isinstance(payload, dict) and payload.get("ok") is True and "data" in payload:
|
||||
return payload["data"]
|
||||
return payload
|
||||
|
||||
|
||||
def dictionaries(value: Any):
|
||||
if isinstance(value, dict):
|
||||
yield value
|
||||
for nested in value.values():
|
||||
yield from dictionaries(nested)
|
||||
elif isinstance(value, list):
|
||||
for nested in value:
|
||||
yield from dictionaries(nested)
|
||||
|
||||
|
||||
def stable_string(value: Any, keys: tuple[str, ...]) -> str:
|
||||
for item in dictionaries(value):
|
||||
for key in keys:
|
||||
candidate = item.get(key)
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
return candidate.strip()
|
||||
raise E2EError("missing_stable_id")
|
||||
|
||||
|
||||
def explicit_list(value: Any, key: str) -> list[Any]:
|
||||
for item in dictionaries(value):
|
||||
if key not in item:
|
||||
continue
|
||||
candidate = item[key]
|
||||
if not isinstance(candidate, list):
|
||||
raise E2EError("malformed_collection")
|
||||
if any(not isinstance(entry, dict) or not entry for entry in candidate):
|
||||
raise E2EError("malformed_collection_item")
|
||||
return candidate
|
||||
raise E2EError("missing_collection")
|
||||
|
||||
|
||||
def require_boolean(value: Any, key: str, expected: bool | None = None) -> bool:
|
||||
for item in dictionaries(value):
|
||||
if key in item:
|
||||
candidate = item[key]
|
||||
if not isinstance(candidate, bool):
|
||||
raise E2EError("malformed_boolean")
|
||||
if expected is not None and candidate is not expected:
|
||||
raise E2EError("boolean_mismatch")
|
||||
return candidate
|
||||
raise E2EError("missing_boolean")
|
||||
|
||||
|
||||
def list_contains_id(value: Any, key: str, expected: str, id_keys: tuple[str, ...]) -> None:
|
||||
rows = explicit_list(business(value), key)
|
||||
for row in rows:
|
||||
for id_key in id_keys:
|
||||
if row.get(id_key) == expected:
|
||||
return
|
||||
raise E2EError("fixture_not_found")
|
||||
|
||||
|
||||
def list_contains_text(value: Any, key: str, expected: str, text_keys: tuple[str, ...]) -> None:
|
||||
rows = explicit_list(business(value), key)
|
||||
for row in rows:
|
||||
if any(row.get(text_key) == expected for text_key in text_keys):
|
||||
return
|
||||
raise E2EError("fixture_not_found")
|
||||
|
||||
|
||||
def iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def cleanup_calendar(event_ids: list[str]) -> int:
|
||||
failures = 0
|
||||
for event_id in reversed(event_ids):
|
||||
try:
|
||||
invoke(["calendar", "event", "delete", "--id", event_id, "--yes"])
|
||||
except E2EError:
|
||||
failures += 1
|
||||
return failures
|
||||
|
||||
|
||||
def cleanup_todo(task_ids: list[str]) -> int:
|
||||
failures = 0
|
||||
for task_id in reversed(task_ids):
|
||||
try:
|
||||
invoke(["todo", "task", "delete", "--task-id", task_id, "--yes"])
|
||||
except E2EError:
|
||||
failures += 1
|
||||
return failures
|
||||
|
||||
|
||||
def audit_cleanup(marker: str, start: datetime, end: datetime) -> None:
|
||||
cursor = ""
|
||||
for _ in range(20):
|
||||
args = [
|
||||
"calendar", "+agenda", "--start", iso(start), "--end", iso(end),
|
||||
"--limit", "100",
|
||||
]
|
||||
if cursor:
|
||||
args.extend(["--cursor", cursor])
|
||||
payload = invoke(args)
|
||||
for event in explicit_list(business(payload), "events"):
|
||||
title = event.get("summary")
|
||||
status = str(event.get("status", "")).lower()
|
||||
if isinstance(title, str) and title.startswith(marker) and status not in {"cancelled", "canceled", "deleted"}:
|
||||
raise E2EError("calendar_cleanup_residual")
|
||||
pagination = (payload.get("meta") or {}).get("pagination") if isinstance(payload, dict) else None
|
||||
if not isinstance(pagination, dict):
|
||||
raise E2EError("cleanup_pagination_missing")
|
||||
if pagination.get("endpoint_exhausted") is True:
|
||||
break
|
||||
next_cursor = pagination.get("next_token")
|
||||
if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor:
|
||||
raise E2EError("cleanup_pagination_stalled")
|
||||
cursor = next_cursor
|
||||
else:
|
||||
raise E2EError("cleanup_page_limit")
|
||||
|
||||
todos = explicit_list(business(invoke(["todo", "+search", "--query", marker])), "todos")
|
||||
if any(isinstance(item.get("subject"), str) and item["subject"].startswith(marker) for item in todos):
|
||||
raise E2EError("todo_cleanup_residual")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not BIN.is_file():
|
||||
print("FAIL preflight binary_missing")
|
||||
return 1
|
||||
|
||||
marker = "DWS-E2E-" + secrets.token_hex(6).upper()
|
||||
profile = business(invoke(["contact", "+me"]))
|
||||
self_id = stable_string(profile, ("userId",))
|
||||
self_name = stable_string(profile, ("name",))
|
||||
|
||||
now = datetime.now().astimezone().replace(microsecond=0)
|
||||
event_start = now + timedelta(minutes=45)
|
||||
event_end = event_start + timedelta(minutes=40)
|
||||
overlap_start = event_start + timedelta(minutes=10)
|
||||
overlap_end = event_end + timedelta(minutes=10)
|
||||
tomorrow = (now + timedelta(days=1)).replace(hour=15, minute=0, second=0)
|
||||
tomorrow_end = tomorrow + timedelta(minutes=30)
|
||||
room_start = (now + timedelta(days=60)).replace(hour=15, minute=0, second=0)
|
||||
room_end = room_start + timedelta(minutes=30)
|
||||
today_start = now.replace(hour=0, minute=0, second=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
week_end = now + timedelta(days=7)
|
||||
|
||||
event_ids: list[str] = []
|
||||
task_ids: list[str] = []
|
||||
primary_event = ""
|
||||
overlap_event = ""
|
||||
book_event = ""
|
||||
primary_task = ""
|
||||
primary_task_title = marker + "-TODO-UPD"
|
||||
|
||||
try:
|
||||
def create_primary_event() -> None:
|
||||
nonlocal primary_event
|
||||
payload = invoke([
|
||||
"calendar", "+create", "--title", marker + "-CAL-A",
|
||||
"--start", iso(event_start), "--end", iso(event_end),
|
||||
"--attendees", self_id,
|
||||
], confirm=True)
|
||||
primary_event = stable_string(business(payload), ("eventId",))
|
||||
event_ids.append(primary_event)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
run_case("calendar", "+create", create_primary_event)
|
||||
if not primary_event:
|
||||
raise E2EError("calendar_fixture_create_failed")
|
||||
|
||||
def create_overlap() -> None:
|
||||
nonlocal overlap_event
|
||||
payload = invoke([
|
||||
"calendar", "+create", "--title", marker + "-CAL-B",
|
||||
"--start", iso(overlap_start), "--end", iso(overlap_end),
|
||||
], confirm=True)
|
||||
overlap_event = stable_string(business(payload), ("eventId",))
|
||||
event_ids.append(overlap_event)
|
||||
create_overlap()
|
||||
|
||||
run_case("calendar", "+get", lambda: stable_string(
|
||||
business(invoke(["calendar", "+get", "--event", primary_event])), ("eventId",)
|
||||
))
|
||||
|
||||
def update_event() -> None:
|
||||
payload = invoke([
|
||||
"calendar", "+update", "--event", primary_event,
|
||||
"--title", marker + "-CAL-A-UPD",
|
||||
], confirm=True)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
run_case("calendar", "+update", update_event)
|
||||
|
||||
def agenda() -> None:
|
||||
payload = invoke([
|
||||
"calendar", "+agenda", "--start", iso(today_start),
|
||||
"--end", iso(today_end), "--limit", "100",
|
||||
])
|
||||
list_contains_id(payload, "events", primary_event, ("eventId", "id"))
|
||||
run_case("calendar", "+agenda", agenda)
|
||||
|
||||
def search_event() -> None:
|
||||
payload = invoke([
|
||||
"calendar", "+search-event", "--query", marker,
|
||||
"--start", iso(today_start), "--end", iso(today_end), "--limit", "100",
|
||||
])
|
||||
list_contains_id(payload, "events", primary_event, ("eventId", "id"))
|
||||
run_case("calendar", "+search-event", search_event)
|
||||
|
||||
def attendee_list() -> None:
|
||||
payload = invoke(["calendar", "+attendee-list", "--event", primary_event])
|
||||
rows = explicit_list(business(payload), "attendees")
|
||||
if not any(row.get("self") is True for row in rows):
|
||||
raise E2EError("self_attendee_not_found")
|
||||
run_case("calendar", "+attendee-list", attendee_list)
|
||||
|
||||
def rsvp() -> None:
|
||||
payload = invoke([
|
||||
"calendar", "+rsvp", "--event", primary_event, "--status", "accept",
|
||||
], confirm=True)
|
||||
require_boolean(business(payload), "success", True)
|
||||
run_case("calendar", "+rsvp", rsvp, blocked_ok=True)
|
||||
|
||||
def invite() -> None:
|
||||
payload = invoke([
|
||||
"calendar", "+invite", "--event", overlap_event, "--with", self_name,
|
||||
], confirm=True)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
run_case("calendar", "+invite", invite)
|
||||
|
||||
def conflicts() -> None:
|
||||
payload = invoke(["calendar", "+conflicts"])
|
||||
rows = explicit_list(business(payload), "conflicts")
|
||||
if not rows:
|
||||
raise E2EError("known_overlap_not_reported")
|
||||
run_case("calendar", "+conflicts", conflicts)
|
||||
|
||||
run_case("calendar", "+today", lambda: list_contains_id(
|
||||
invoke(["calendar", "+today"]), "events", primary_event, ("eventId", "id")
|
||||
))
|
||||
run_case("calendar", "+week", lambda: list_contains_id(
|
||||
invoke(["calendar", "+week"]), "events", primary_event, ("eventId", "id")
|
||||
))
|
||||
|
||||
def next_event() -> None:
|
||||
payload = business(invoke(["calendar", "+next-event"]))
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("event"), dict):
|
||||
raise E2EError("missing_next_event")
|
||||
run_case("calendar", "+next-event", next_event)
|
||||
|
||||
def free_slots() -> None:
|
||||
payload = business(invoke(["calendar", "+free-slots", "--from", "0", "--to", "24"]))
|
||||
explicit_list(payload, "freeSlots")
|
||||
require_boolean(payload, "complete", True)
|
||||
run_case("calendar", "+free-slots", free_slots)
|
||||
|
||||
def freebusy() -> None:
|
||||
payload = business(invoke([
|
||||
"calendar", "+freebusy", "--users", self_id,
|
||||
"--start", iso(event_start - timedelta(minutes=5)),
|
||||
"--end", iso(event_end + timedelta(minutes=5)),
|
||||
]))
|
||||
explicit_list(payload, "busy")
|
||||
run_case("calendar", "+freebusy", freebusy)
|
||||
|
||||
run_case("calendar", "+free", lambda: explicit_list(business(invoke([
|
||||
"calendar", "+free", "--who", self_name,
|
||||
"--start", iso(event_start - timedelta(minutes=5)),
|
||||
"--end", iso(event_end + timedelta(minutes=5)),
|
||||
])), "busy"))
|
||||
|
||||
def my_free() -> None:
|
||||
payload = business(invoke([
|
||||
"calendar", "+my-free", "--start", iso(event_start - timedelta(minutes=5)),
|
||||
"--end", iso(event_end + timedelta(minutes=5)),
|
||||
]))
|
||||
require_boolean(payload, "free")
|
||||
run_case("calendar", "+my-free", my_free)
|
||||
|
||||
run_case("calendar", "+suggestion", lambda: explicit_list(business(invoke([
|
||||
"calendar", "+suggestion", "--users", self_id, "--duration", "30",
|
||||
"--start", iso(room_start), "--end", iso(room_start + timedelta(hours=4)),
|
||||
])), "suggestions"))
|
||||
run_case("calendar", "+suggest-time", lambda: explicit_list(business(invoke([
|
||||
"calendar", "+suggest-time", "--with", self_name, "--duration", "30",
|
||||
"--start", iso(room_start), "--end", iso(room_start + timedelta(hours=4)),
|
||||
])), "suggestions"))
|
||||
|
||||
def book() -> None:
|
||||
nonlocal book_event
|
||||
payload = invoke([
|
||||
"calendar", "+book", "--title", marker + "-CAL-TOMORROW",
|
||||
"--start", iso(tomorrow), "--end", iso(tomorrow_end), "--with", self_name,
|
||||
], confirm=True)
|
||||
book_event = stable_string(business(payload), ("eventId", "id"))
|
||||
event_ids.append(book_event)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
run_case("calendar", "+book", book)
|
||||
if book_event:
|
||||
run_case("calendar", "+tomorrow", lambda: list_contains_id(
|
||||
invoke(["calendar", "+tomorrow"]), "events", book_event, ("eventId", "id")
|
||||
))
|
||||
|
||||
def reschedule() -> None:
|
||||
payload = invoke([
|
||||
"calendar", "+reschedule", "--event", book_event,
|
||||
"--start", iso(tomorrow + timedelta(hours=1)),
|
||||
"--end", iso(tomorrow_end + timedelta(hours=1)),
|
||||
], confirm=True)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
run_case("calendar", "+reschedule", reschedule)
|
||||
|
||||
def cancel_event() -> None:
|
||||
payload = business(invoke(["calendar", "+cancel-event", "--event", book_event], confirm=True))
|
||||
require_boolean(payload, "verified", True)
|
||||
tombstone = business(invoke(["calendar", "+get", "--event", book_event]))
|
||||
status = stable_string(tombstone, ("status", "eventStatus", "event_status")).lower()
|
||||
if status not in {"cancelled", "canceled", "deleted"}:
|
||||
raise E2EError("delete_tombstone_missing")
|
||||
event_ids.remove(book_event)
|
||||
run_case("calendar", "+cancel-event", cancel_event)
|
||||
else:
|
||||
for command in ("+tomorrow", "+reschedule", "+cancel-event"):
|
||||
record("calendar", command, "BLOCKED", "book_fixture_unavailable")
|
||||
|
||||
run_case("calendar", "+room-groups", lambda: explicit_list(
|
||||
business(invoke(["calendar", "+room-groups", "--page", "0", "--limit", "20"])),
|
||||
"groups",
|
||||
), blocked_ok=True)
|
||||
|
||||
room_payload: Any = None
|
||||
def room_find() -> None:
|
||||
nonlocal room_payload
|
||||
room_payload = invoke([
|
||||
"calendar", "+room-find", "--start", iso(room_start), "--end", iso(room_end),
|
||||
"--page", "0", "--limit", "100",
|
||||
])
|
||||
rows = explicit_list(business(room_payload), "rooms")
|
||||
if not rows:
|
||||
raise E2EError("safe_room_fixture_empty")
|
||||
run_case("calendar", "+room-find", room_find, blocked_ok=True)
|
||||
|
||||
room_name = ""
|
||||
if room_payload is not None:
|
||||
try:
|
||||
room_name = stable_string(explicit_list(business(room_payload), "rooms"), ("roomName", "name"))
|
||||
except E2EError:
|
||||
room_name = ""
|
||||
if room_name:
|
||||
run_case("calendar", "+room-search", lambda: explicit_list(
|
||||
business(invoke(["calendar", "+room-search", "--room-name", room_name])), "rooms"
|
||||
), blocked_ok=True)
|
||||
else:
|
||||
try:
|
||||
explicit_list(
|
||||
business(invoke(["calendar", "+room-search", "--room-name", marker])),
|
||||
"rooms",
|
||||
)
|
||||
except E2EError as exc:
|
||||
record("calendar", "+room-search", "FAIL", exc.reason)
|
||||
else:
|
||||
record("calendar", "+room-search", "BLOCKED", "nonempty_room_fixture_unavailable")
|
||||
|
||||
def book_list() -> None:
|
||||
payload = business(invoke(["calendar", "+book-list"]))
|
||||
rows = explicit_list(payload, "calendars")
|
||||
if not rows:
|
||||
raise E2EError("calendar_book_fixture_empty")
|
||||
run_case("calendar", "+book-list", book_list)
|
||||
|
||||
book_list_payload = invoke(["calendar", "+book-list"])
|
||||
try:
|
||||
book_name = stable_string(explicit_list(business(book_list_payload), "calendars"), ("name", "summary", "title"))
|
||||
run_case("calendar", "+book-search", lambda: explicit_list(
|
||||
business(invoke(["calendar", "+book-search", "--query", book_name])), "calendars"
|
||||
))
|
||||
except E2EError as exc:
|
||||
record("calendar", "+book-search", "FAIL", exc.reason)
|
||||
|
||||
def create_primary_todo() -> None:
|
||||
nonlocal primary_task
|
||||
payload = invoke([
|
||||
"todo", "+create", "--title", marker + "-TODO",
|
||||
"--executors", self_id, "--due", iso(now + timedelta(hours=3)),
|
||||
"--priority", "20",
|
||||
], confirm=True)
|
||||
primary_task = stable_string(business(payload), ("taskId",))
|
||||
task_ids.append(primary_task)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
run_case("todo", "+create", create_primary_todo)
|
||||
if not primary_task:
|
||||
raise E2EError("todo_fixture_create_failed")
|
||||
|
||||
run_case("todo", "+get", lambda: stable_string(
|
||||
business(invoke(["todo", "+get", "--task-id", primary_task])), ("taskId",)
|
||||
))
|
||||
|
||||
def update_todo() -> None:
|
||||
payload = invoke([
|
||||
"todo", "+update", "--task-id", primary_task,
|
||||
"--title", primary_task_title, "--priority", "30",
|
||||
], confirm=True)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
run_case("todo", "+update", update_todo)
|
||||
|
||||
run_case("todo", "+get-my-tasks", lambda: list_contains_id(
|
||||
invoke(["todo", "+get-my-tasks", "--all", "--max-pages", "40"]),
|
||||
"todos", primary_task, ("taskId", "id")
|
||||
))
|
||||
run_case("todo", "+get-related-tasks", lambda: list_contains_id(
|
||||
invoke(["todo", "+get-related-tasks"]), "tasks", primary_task, ("taskId", "id")
|
||||
))
|
||||
run_case("todo", "+search", lambda: list_contains_id(
|
||||
invoke(["todo", "+search", "--query", marker]), "todos", primary_task, ("taskId", "id")
|
||||
))
|
||||
run_case("todo", "+created-todos", lambda: list_contains_id(
|
||||
invoke(["todo", "+created-todos"]), "created", primary_task, ("taskId", "id")
|
||||
))
|
||||
run_case("todo", "+due-today", lambda: list_contains_id(
|
||||
invoke(["todo", "+due-today"]), "tasks", primary_task, ("taskId", "id")
|
||||
))
|
||||
|
||||
def add_comment() -> None:
|
||||
payload = invoke([
|
||||
"todo", "+comment", "--task-id", primary_task, "--content", marker + "-COMMENT",
|
||||
], confirm=True)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
run_case("todo", "+comment", add_comment)
|
||||
run_case("todo", "+list-comment", lambda: explicit_list(
|
||||
business(invoke(["todo", "+list-comment", "--task-id", primary_task])), "comments"
|
||||
))
|
||||
|
||||
def reminder() -> None:
|
||||
first = invoke([
|
||||
"todo", "+reminder", "--task-id", primary_task,
|
||||
"--base-time", "dueTime", "--due-date-offset", "-30",
|
||||
], confirm=True)
|
||||
require_boolean(business(first), "terminalReceipt", True)
|
||||
require_boolean(business(first), "verified", False)
|
||||
second = invoke([
|
||||
"todo", "+reminder", "--task-id", primary_task, "--clear",
|
||||
], confirm=True)
|
||||
require_boolean(business(second), "terminalReceipt", True)
|
||||
run_case("todo", "+reminder", reminder)
|
||||
|
||||
def create_sub_fixture() -> None:
|
||||
payload = invoke([
|
||||
"todo", "task", "create-sub", "--parent-id", primary_task,
|
||||
"--title", marker + "-SUB", "--executors", self_id,
|
||||
], confirm=True)
|
||||
task_ids.append(stable_string(business(payload), ("taskId", "id")))
|
||||
create_sub_fixture()
|
||||
run_case("todo", "+list-sub", lambda: explicit_list(
|
||||
business(invoke(["todo", "+list-sub", "--task-id", primary_task])), "subTasks"
|
||||
))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dws-ct-e2e-") as tmp:
|
||||
attachment = Path(tmp) / "fixture.txt"
|
||||
attachment.write_text(marker + "\n", encoding="utf-8")
|
||||
invoke([
|
||||
"todo", "task", "add-attachment", "--task-id", primary_task,
|
||||
"--file-path", str(attachment),
|
||||
], confirm=True)
|
||||
run_case("todo", "+list-attachment", lambda: explicit_list(
|
||||
business(invoke(["todo", "+list-attachment", "--task-id", primary_task])), "attachments"
|
||||
))
|
||||
|
||||
def complete() -> None:
|
||||
payload = invoke(["todo", "+complete", "--task-id", primary_task], confirm=True)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
require_boolean(business(payload), "isDone", True)
|
||||
run_case("todo", "+complete", complete)
|
||||
|
||||
def reopen() -> None:
|
||||
payload = invoke(["todo", "+reopen", "--task-id", primary_task], confirm=True)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
require_boolean(business(payload), "isDone", False)
|
||||
run_case("todo", "+reopen", reopen)
|
||||
|
||||
def assigned(command: str, args: list[str]) -> None:
|
||||
payload = invoke(["todo", command, *args], confirm=True)
|
||||
task_id = stable_string(business(payload), ("taskId",))
|
||||
task_ids.append(task_id)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
|
||||
run_case("todo", "+assign", lambda: assigned(
|
||||
"+assign", ["--task", marker + "-ASSIGN", "--to", self_name, "--due", iso(now + timedelta(hours=4))]
|
||||
))
|
||||
run_case("todo", "+assign-multi", lambda: assigned(
|
||||
"+assign-multi", ["--task", marker + "-ASSIGN-MULTI", "--to", self_name]
|
||||
))
|
||||
run_case("todo", "+remind", lambda: assigned(
|
||||
"+remind", ["--task", marker + "-REMIND", "--at", iso(now + timedelta(hours=5))]
|
||||
))
|
||||
|
||||
overdue_task = stable_string(business(invoke([
|
||||
"todo", "+create", "--title", marker + "-OVERDUE", "--executors", self_id,
|
||||
"--due", iso(now - timedelta(hours=2)),
|
||||
], confirm=True)), ("taskId",))
|
||||
task_ids.append(overdue_task)
|
||||
run_case("todo", "+overdue", lambda: list_contains_id(
|
||||
invoke(["todo", "+overdue"]), "overdue", overdue_task, ("taskId", "id")
|
||||
))
|
||||
|
||||
def todo_done() -> None:
|
||||
payload = invoke(["todo", "+todo-done", "--task", primary_task_title], confirm=True)
|
||||
require_boolean(business(payload), "verified", True)
|
||||
run_case("todo", "+todo-done", todo_done)
|
||||
|
||||
invoke(["todo", "+upload-attachment", "--task-id", primary_task, "--file-path", "fixture"], expect_success=False)
|
||||
|
||||
except E2EError as exc:
|
||||
record("preflight", "flow", "FAIL", exc.reason)
|
||||
finally:
|
||||
cleanup_failures = cleanup_calendar(event_ids) + cleanup_todo(task_ids)
|
||||
try:
|
||||
audit_cleanup(marker, now - timedelta(days=1), now + timedelta(days=70))
|
||||
except E2EError as exc:
|
||||
record("cleanup", "resource-audit", "FAIL", exc.reason)
|
||||
else:
|
||||
if cleanup_failures:
|
||||
record("cleanup", "resource-audit", "FAIL", "cleanup_command_failed")
|
||||
else:
|
||||
record("cleanup", "resource-audit", "PASS")
|
||||
|
||||
for command in sorted(CALENDAR_CASES):
|
||||
results.setdefault(f"calendar/{command}", ("FAIL", "case_not_executed"))
|
||||
for command in sorted(TODO_CASES):
|
||||
results.setdefault(f"todo/{command}", ("FAIL", "case_not_executed"))
|
||||
|
||||
failed = False
|
||||
for label in sorted(results):
|
||||
status, reason = results[label]
|
||||
if status == "FAIL":
|
||||
failed = True
|
||||
suffix = f" {reason}" if reason else ""
|
||||
print(f"{status} {label}{suffix}")
|
||||
counts = {status: sum(1 for value in results.values() if value[0] == status) for status in ("PASS", "BLOCKED", "FAIL")}
|
||||
print(f"SUMMARY pass={counts['PASS']} blocked={counts['BLOCKED']} fail={counts['FAIL']}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -25,6 +25,8 @@ SEMANTIC_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_drive.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_calendar.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ cli_version: ">=1.0.15"
|
||||
|---|---:|---|
|
||||
| `aitable` | 92 | `dingtalk-aitable` |
|
||||
| `attendance` | 19 | `dingtalk-misc` |
|
||||
| `calendar` | 20 | `dingtalk-calendar` |
|
||||
| `calendar` | 27 | `dingtalk-calendar` |
|
||||
| `chat` | 98 | `dingtalk-chat` |
|
||||
| `contact` | 14 | `dingtalk-contact` |
|
||||
| `devapp` | 19 | `dingtalk-misc` |
|
||||
@@ -57,7 +57,7 @@ cli_version: ">=1.0.15"
|
||||
| `oa` | 7 | `dingtalk-misc` |
|
||||
| `report` | 2 | `dingtalk-misc` |
|
||||
| `sheet` | 2 | `dingtalk-misc` |
|
||||
| `todo` | 11 | `dingtalk-todo` |
|
||||
| `todo` | 21 | `dingtalk-todo` |
|
||||
| `wiki` | 20 | `dingtalk-wiki` |
|
||||
<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ dws calendar event [create|update|get|delete|respond] [flags]
|
||||
dws calendar event list [flags]
|
||||
# 查询循环日程的实例列表(按时间范围展开重复日程)
|
||||
dws calendar event instances [flags]
|
||||
# 获取日程的分享信息(日程主题、组织人、地点、入会信息等,用于向他人分享日程)
|
||||
dws calendar event share-info [flags]
|
||||
# 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案
|
||||
dws calendar event suggest [flags]
|
||||
```
|
||||
@@ -160,6 +162,22 @@ Flags:
|
||||
> **默认行为**:不传 `--start` / `--end` 时,默认返回今天的实例(00:00:00 ~ 23:59:59)。
|
||||
> **分页**:单次最多返回 `--limit` 指定的条数(默认/最大 100);当结果超过 limit 时,返回体包含 `nextCursor` 字段。
|
||||
|
||||
### 获取日程分享信息
|
||||
```
|
||||
Usage:
|
||||
dws calendar event share-info [flags]
|
||||
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
|
||||
Flags:
|
||||
--id string 日程 ID (必填)
|
||||
--calendar-id string 日历 ID (可选,默认 primary 主日历)
|
||||
--language string 语言代码 (可选,如 zh-CN)
|
||||
```
|
||||
|
||||
> **说明**:根据日程 ID 获取日程的分享信息,展示日程主题、组织人、地点、入会信息等,用于向他人分享日程(如发送到群聊、邮件)。中文内容建议传 `--language zh-CN`。
|
||||
|
||||
### 创建日程
|
||||
```
|
||||
Usage:
|
||||
@@ -585,6 +603,7 @@ Flags:
|
||||
- 推荐时间/什么时候有空/协调时间 → `event suggest`
|
||||
- 接受/拒绝/暂定日程 → `event respond`
|
||||
- 查询循环日程/重复日程的每次实例/展开循环日程 → `event instances`
|
||||
- 分享日程/把日程发给别人/获取日程分享信息或入会信息 → `event share-info`
|
||||
|
||||
用户说"参会人/与会者":
|
||||
- 查看 → `attendee list`
|
||||
@@ -691,6 +710,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
|
||||
| `event suggest` | 推荐的时间段 | event create 的 --start/--end |
|
||||
| `event respond` | 响应结果 | — |
|
||||
| `event instances` | `result.events[].id`, `nextCursor` | event get/update/delete/respond 的 --id;下一页 --cursor |
|
||||
| `event share-info` | 日程分享信息(主题、组织人、地点、入会信息等) | 分享给他人(如 chat/mail 发送) |
|
||||
| `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms |
|
||||
| `room list-groups` | `groups[].groupId` | room search 的 --group-id |
|
||||
| `book list` | `id`(如 `primary`) | event list/get 的 --calendar-id, book get/update 的 --id |
|
||||
@@ -718,6 +738,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
|
||||
- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历
|
||||
- **分页查询**:`event list` / `event instances` 均支持 `--limit`(控制每页条数,默认/最大 100)和 `--cursor`(翻页游标);**首次查询无需传 `--cursor`**,仅当返回体中包含 `nextCursor` 时,将其作为 `--cursor` 传入可获取下一页
|
||||
- **循环日程实例**:`event instances` 用于按时间范围展开重复日程(SeriesMaster)的每一个实例;**普通非循环日程调用该命令将查不到任何实例信息**
|
||||
- **日程分享**:`event share-info` 获取日程的分享信息(主题、组织人、地点、入会信息等);`--language` 控制文案语言(中文场景传 zh-CN)
|
||||
- **日程提醒**:`event create` 支持 `--remind-minutes` 设置开始前提醒,逗号分隔多个分钟数(如 `--remind-minutes 5,10,15`),不传则默认15分钟提醒
|
||||
- **会议室分页**:`room search` 支持 `--limit`(每页条数,默认100,最大100)和 `--page`(分页起始位置,默认0),与 `room list-groups` 分页风格一致
|
||||
|
||||
|
||||
@@ -738,7 +738,7 @@ Flags:
|
||||
|
||||
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
|
||||
|
||||
群聊传 --group;单聊可传 --users、--open-dingtalk-ids 或两者组合。--group 不能与单聊目标同时指定。默认发送 Markdown,必须同时使用 --title 和 --text;公网图片 URL 使用 --msg-type image --image-url <图片 URL>;本地图片和其他本地文件一样使用 --msg-type file --file-path <本地路径>,CLI 会完成上传并按文件附件发送。群聊时可选 --at-user-ids 或 --at-open-dingtalk-ids @指定成员。
|
||||
群聊传 --conversation-id;单聊可传 --users、--open-dingtalk-ids 或两者组合。--conversation-id 不能与单聊目标同时指定。默认发送 Markdown,普通发送必须同时使用 --title 和 --text;公网图片 URL 使用 --msg-type image --image-url <图片 URL>;本地图片和其他本地文件一样使用 --msg-type file --file-path <本地路径>,CLI 会完成上传并按文件附件发送。群聊时可选 --at-user-ids 或 --at-open-dingtalk-ids @指定成员。机器人群聊 Markdown 引用回复同时传 --reply <openMessageId> 和 --ref-sender <senderOpenDingTalkId>,可省略 --title 由 CLI 从正文生成;单聊、图片和文件不支持引用回复。
|
||||
如果用户明确要求"用机器人/机器人身份/robot"发送,必须使用本命令,严禁改用 `chat message send` 以当前用户身份发送。
|
||||
|
||||
**重要**:机器人发群消息前,必须确认该机器人已在目标群中。若机器人不在群内会报错"机器人不存在",需先执行 `dws chat group members add-bot --id <openConversationId> --robot-code <robot-code>` 将机器人加入群聊后再发送。
|
||||
@@ -746,7 +746,8 @@ Flags:
|
||||
Usage:
|
||||
dws chat message send-by-bot [flags]
|
||||
Example:
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
|
||||
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --title "日报" --text "## 今日完成..."
|
||||
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --reply <openMessageId> --ref-sender <senderOpenDingTalkId> --text "收到"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --msg-type image --image-url "https://example.com/image.png"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --msg-type file --file-path ./report.pdf
|
||||
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
|
||||
@@ -755,10 +756,10 @@ Example:
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-all --title "通知" --text "请所有人注意"
|
||||
Flags:
|
||||
--group string 群聊 openConversationId(群聊时必填)
|
||||
--conversation-id string 群聊 openConversationId(群聊时必填;兼容别名 --group)
|
||||
--robot-code string 机器人 Code (必填)
|
||||
--msg-type string 消息类型:markdown、image 或 file;省略时为 markdown;公网图片使用 image --image-url;本地图片和文件使用 file --file-path
|
||||
--title string Markdown 消息标题(Markdown 时必填)
|
||||
--title string Markdown 消息标题(普通 Markdown 必填;引用回复省略时从正文生成)
|
||||
--text string Markdown 消息内容(Markdown 时必填)
|
||||
--image-url string 公网图片 URL(msgType=image 时必填)
|
||||
--file-path string 本地图片或文件路径(msgType=file 时上传并按文件附件发送)
|
||||
@@ -767,6 +768,8 @@ Flags:
|
||||
--at-user-ids string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
|
||||
--at-open-dingtalk-ids string @指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)
|
||||
--at-all @所有人(可选),服务端接收字符串 true/false
|
||||
--reply string 被引用消息的 openMessageId(仅群聊 Markdown;必须与 --ref-sender 同时使用)
|
||||
--ref-sender string 被引用消息发送者的 openDingTalkId(仅群聊 Markdown;必须与 --reply 同时使用)
|
||||
|
||||
注意:
|
||||
- 用户明确要求机器人发送时,必须使用 `chat message send-by-bot`;严禁使用 `chat message send` 以用户身份代发
|
||||
@@ -776,6 +779,7 @@ Flags:
|
||||
- --at-user-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @userId 对应文本
|
||||
- --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @openDingtalkId 对应文本
|
||||
- --at-all @所有人,仅群聊时生效;只需带上 --at-all flag 即可,服务端会自动处理
|
||||
- --reply 与 --ref-sender 必须成对使用;CLI 在普通群消息参数顶层透传 referenceOpenMessageId 与 srcMsgSendOpenDingTalkId,不设置 msgType=reply
|
||||
- userId 获取方式:`dws contact user search --query "姓名"` 搜人获取 userId
|
||||
- **换行符**:--text 按 Markdown 渲染,换行规则同 `chat message send`:
|
||||
1. 必须使用**真实换行符**(`U+000A`),而非字面量 `\n`,否则全部内容会渲染在同一行
|
||||
@@ -2146,6 +2150,7 @@ Flags:
|
||||
用户说"入群验证记录/谁申请进群" → `chat group list-join-validations`
|
||||
用户说"审批入群/通过入群申请/拒绝入群申请" → `chat group audit-join-validation`
|
||||
用户说"引用回复/回复消息/引用消息回复" → `chat message reply`
|
||||
用户说"机器人引用回复/让机器人回复这条群消息" → `chat message send-by-bot --conversation-id <openConversationId> --reply <openMessageId> --ref-sender <senderOpenDingTalkId> --text <内容>`
|
||||
用户说"转发消息/转发一条消息/把消息转发到另一个群" → `chat message forward`
|
||||
用户说"合并转发/批量转发/合并转发多条消息" → `chat message combine-forward`
|
||||
用户说"转发话题/转发话题消息" → `chat message forward-topic`
|
||||
@@ -2183,7 +2188,7 @@ Flags:
|
||||
- `chat message send` — 以当前用户身份发消息(群聊或单聊),text 为位置参数;本地图片/文件/音视频统一用 `--msg-type file --file-path`,其中图片显示为可下载附件而非内联图片;`--msg-type image --media-id` 只用于上游已经提供有效 mediaId 的场景,DWS CLI 不能从本地文件生成 mediaId
|
||||
- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群)
|
||||
- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在)
|
||||
- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊);Markdown 使用 `--text`,公网图片使用 `--msg-type image --image-url`,本地图片和文件使用 `--msg-type file --file-path`
|
||||
- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊);Markdown 使用 `--text`,公网图片使用 `--msg-type image --image-url`,本地图片和文件使用 `--msg-type file --file-path`;群聊 Markdown 引用回复成对使用 `--reply` / `--ref-sender`
|
||||
- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息
|
||||
- `chat message recall-by-bot` — 通过**机器人接口**撤回机器人发出的消息,需要 `--robot-code` + `--keys`(发送时返回的 processQueryKey);传 `--group` 为群聊撤回,不传为单聊撤回
|
||||
- `chat message recall` — 通过 **IM 接口**撤回当前用户自己发出的消息,需要 `--conversation-id`(openConversationId)+ `--msg-id`(openMessageId,可通过 `chat message list` 获取);群聊单聊均通过 `--conversation-id` 区分
|
||||
@@ -2456,7 +2461,7 @@ Flags:
|
||||
| `chat category list` | `categoryId` | category list-conversations 的 --category-id |
|
||||
| `chat group get-by-group-id` | `openConversationId` | 同 chat search,将群号转为 openConversationId |
|
||||
| `chat message send-card` | `bizId` | update-card 的 --biz-id |
|
||||
| `chat message list` | `openMessageId` | message reply 的 --ref-msg-id、message forward 的 --msg-id |
|
||||
| `chat message list` | `openMessageId` | message reply 的 --ref-msg-id、send-by-bot 机器人群聊引用回复的 --reply、message forward 的 --msg-id |
|
||||
| `chat search` | `openConversationId` | set-top 的 --conversation-id、group-mute / group-mute-member 的 --group |
|
||||
|
||||
## 注意事项
|
||||
@@ -2485,7 +2490,7 @@ Flags:
|
||||
- `--user` 和 `--open-dingtalk-id` 本质上都是发起单聊操作,只是用户标识格式不同:userId 为企业内部应用常用标识,openDingTalkId 为三方应用或跨组织场景下的用户标识,服务端对两种 ID 的解析逻辑不同
|
||||
- `--time` 格式: `yyyy-MM-dd HH:mm:ss`,为拉取消息的起始时间点;`--direction` 控制方向(newer=从给定时间往现在拉,older=从给定时间往以前拉),`--limit` 控制数量
|
||||
- `chat search` 挂在 `chat` 下(非 `chat group` 下),路径为 `dws chat search`
|
||||
- `send-by-bot` 群聊传 `--group`,单聊传 `--users` 或 `--open-dingtalk-ids`,与 `--group` 互斥且必选其一;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingtalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --group <openConversationId> --robot-code <robot-code>` 将机器人邀请进群后再发送
|
||||
- `send-by-bot` 群聊传 `--conversation-id`,单聊传 `--users` 或 `--open-dingtalk-ids`,与群目标互斥且必选其一;机器人群聊 Markdown 引用回复必须同时传 `--reply <openMessageId>` 与 `--ref-sender <senderOpenDingTalkId>`,单聊、图片和文件不支持引用回复;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingTalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --conversation-id <openConversationId> --robot-code <robot-code>` 将机器人邀请进群后再发送
|
||||
- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
|
||||
- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--text` 中包含 `@10`
|
||||
- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user <userId>`
|
||||
|
||||
@@ -38,6 +38,7 @@ metadata:
|
||||
| `dws calendar +my-free` | read | 查我自己在某时间段的忙闲(默认今天,无需输入姓名) |
|
||||
| `dws calendar +next-event` | read | 查看接下来最近的一个日程(默认扫描未来 7 天) |
|
||||
| `dws calendar +reschedule` | write | 改一个已有日程的时间(只动开始/结束时间,其他字段不变) |
|
||||
| `dws calendar +room-find` | read | 按时间段搜索可用会议室(不传时间默认当前起 1 小时) |
|
||||
| `dws calendar +room-groups` | read | 会议室分组列表 |
|
||||
| `dws calendar +room-search` | read | 按名称模糊搜索会议室(不检查可用性) |
|
||||
| `dws calendar +suggest-time` | read | 按姓名解析多位参与者,推荐大家都有空的可开会时间段(自动解析 userId) |
|
||||
|
||||
@@ -44,6 +44,8 @@ dws calendar event [create|update|get|delete|respond] [flags]
|
||||
dws calendar event list [flags]
|
||||
# 查询循环日程的实例列表(按时间范围展开重复日程)
|
||||
dws calendar event instances [flags]
|
||||
# 获取日程的分享信息(日程主题、组织人、地点、入会信息等,用于向他人分享日程)
|
||||
dws calendar event share-info [flags]
|
||||
# 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案
|
||||
dws calendar event suggest [flags]
|
||||
```
|
||||
@@ -160,6 +162,22 @@ Flags:
|
||||
> **默认行为**:不传 `--start` / `--end` 时,默认返回今天的实例(00:00:00 ~ 23:59:59)。
|
||||
> **分页**:单次最多返回 `--limit` 指定的条数(默认/最大 100);当结果超过 limit 时,返回体包含 `nextCursor` 字段。
|
||||
|
||||
### 获取日程分享信息
|
||||
```
|
||||
Usage:
|
||||
dws calendar event share-info [flags]
|
||||
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
|
||||
Flags:
|
||||
--id string 日程 ID (必填)
|
||||
--calendar-id string 日历 ID (可选,默认 primary 主日历)
|
||||
--language string 语言代码 (可选,如 zh-CN)
|
||||
```
|
||||
|
||||
> **说明**:根据日程 ID 获取日程的分享信息,展示日程主题、组织人、地点、入会信息等,用于向他人分享日程(如发送到群聊、邮件)。中文内容建议传 `--language zh-CN`。
|
||||
|
||||
### 创建日程
|
||||
```
|
||||
Usage:
|
||||
@@ -585,6 +603,7 @@ Flags:
|
||||
- 推荐时间/什么时候有空/协调时间 → `event suggest`
|
||||
- 接受/拒绝/暂定日程 → `event respond`
|
||||
- 查询循环日程/重复日程的每次实例/展开循环日程 → `event instances`
|
||||
- 分享日程/把日程发给别人/获取日程分享信息或入会信息 → `event share-info`
|
||||
|
||||
用户说"参会人/与会者":
|
||||
- 查看 → `attendee list`
|
||||
@@ -691,6 +710,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
|
||||
| `event suggest` | 推荐的时间段 | event create 的 --start/--end |
|
||||
| `event respond` | 响应结果 | — |
|
||||
| `event instances` | `result.events[].id`, `nextCursor` | event get/update/delete/respond 的 --id;下一页 --cursor |
|
||||
| `event share-info` | 日程分享信息(主题、组织人、地点、入会信息等) | 分享给他人(如 chat/mail 发送) |
|
||||
| `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms |
|
||||
| `room list-groups` | `groups[].groupId` | room search 的 --group-id |
|
||||
| `book list` | `id`(如 `primary`) | event list/get 的 --calendar-id, book get/update 的 --id |
|
||||
@@ -718,6 +738,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
|
||||
- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历
|
||||
- **分页查询**:`event list` / `event instances` 均支持 `--limit`(控制每页条数,默认/最大 100)和 `--cursor`(翻页游标);**首次查询无需传 `--cursor`**,仅当返回体中包含 `nextCursor` 时,将其作为 `--cursor` 传入可获取下一页
|
||||
- **循环日程实例**:`event instances` 用于按时间范围展开重复日程(SeriesMaster)的每一个实例;**普通非循环日程调用该命令将查不到任何实例信息**
|
||||
- **日程分享**:`event share-info` 获取日程的分享信息(主题、组织人、地点、入会信息等);`--language` 控制文案语言(中文场景传 zh-CN)
|
||||
- **日程提醒**:`event create` 支持 `--remind-minutes` 设置开始前提醒,逗号分隔多个分钟数(如 `--remind-minutes 5,10,15`),不传则默认15分钟提醒
|
||||
- **会议室分页**:`room search` 支持 `--limit`(每页条数,默认100,最大100)和 `--page`(分页起始位置,默认0),与 `room list-groups` 分页风格一致
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ metadata:
|
||||
| 已知资源引用单独下载 | `dws chat +messages-resource-download` |
|
||||
| 按关键词搜索群 | `dws chat +chat-search` |
|
||||
| 查看消息收藏 | `dws chat +flag-list` |
|
||||
| <!-- dws-intent: chat.reply.quote -->引用回复 | `dws chat +messages-reply`;成功结果保留新消息/会话/投递与原消息来源上下文 |
|
||||
| <!-- dws-intent: chat.reply.quote -->引用回复 | 人:`dws chat +messages-reply`;成功结果保留新消息/会话/投递与原消息来源上下文。Bot 群:`dws chat message send-by-bot --conversation-id <cid> --reply <mid> --ref-sender <sid>` |
|
||||
| 撤回当前用户消息 | `dws chat +messages-recall --msg-id <openMessageId>`;可省略会话 ID,由 CLI 只读补齐;兼容单值 `--message-ids` |
|
||||
| 已知话题主消息 ID 或 thread/topic ID 读取回复 | `dws chat +thread-replies` |
|
||||
| <!-- dws-intent: chat.create.group -->按成员 ID 或姓名创建群聊 | `dws chat +chat-create`;成员/群主均可自然解析,任一歧义都会在创建前整体停止 |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
## 群聊消息
|
||||
|
||||
<!-- dws-intent: chat.read.conversation -->读取或导出指定群聊/单聊的消息记录时使用 `dws chat +chat-messages`。可附带非必填的 `--sender-query <姓名>`:没有稳定 ID 时保留全部消息,唯一解析成功后按 userId/openDingTalkId 筛选同一次读取结果并返回 `resolvedFilters`,不要补跑 `+search-msg`。
|
||||
<!-- dws-intent: chat.read.conversation -->读取或导出指定群聊/单聊的消息记录时使用 `dws chat +chat-messages`。可附带非必填的 `--sender-query <姓名>`:唯一解析成功后按稳定 `senderId` 筛选同一次读取结果并返回 `resolvedFilters`;解析失败、不完整或存在歧义时抑制未过滤消息并返回 `sender_resolution_failed`,不要补跑 `+search-msg`。混合姓名/ID 入口 `--sender` 无法经通讯录确认类型时可按原值 userId 精确过滤,但必须保留 `identity_unverified`。
|
||||
|
||||
用户以发送者、关键词、@对象或消息类型为主要条件直接检索时优先使用 `+search-msg`;范围可以是单个、多个或全部会话。
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ Favorite、消息 Pin、消息 Top 与会话 Top 是四种对象,不能互换
|
||||
|---|---|
|
||||
| `chat bot search` | 搜索当前用户创建的机器人并取得 `robotCode` |
|
||||
| `chat bot find` | 搜索可用机器人并取得机器人 `openDingTalkId` |
|
||||
| `chat message send-by-bot` | `+messages-send --as bot` 未发布的真实底层字段 |
|
||||
| `chat message send-by-bot` | `+messages-send --as bot` 未发布的真实底层字段,包括机器人群聊引用回复的 `--reply` / `--ref-sender` |
|
||||
| `chat message recall-by-bot` | 使用 `processQueryKey` 撤回机器人消息 |
|
||||
| `chat message send-by-webhook` | `+messages-send --as webhook` 未发布的真实底层字段 |
|
||||
|
||||
@@ -124,7 +124,7 @@ Favorite、消息 Pin、消息 Top 与会话 Top 是四种对象,不能互换
|
||||
| `+messages-send` | `openTaskId` 查询投递状态;它不是消息 ID |
|
||||
| `+chat-messages` / `+search-msg` / `+messages-mget` | 回复、转发、撤回、资源操作使用的真实消息/会话/thread ID |
|
||||
| `chat bot search` | `robotCode`;不能当机器人 `openDingTalkId` |
|
||||
| `chat message send-by-bot` | `processQueryKey`,仅用于机器人撤回 |
|
||||
| `chat message send-by-bot` | `processQueryKey` 用于机器人撤回;群聊引用回复还需消息查询返回的 `openMessageId` 与原发送者 `openDingTalkId` |
|
||||
|
||||
显式稳定 ID 当前不携带可验证的 profile provenance;调用方必须保证来源,不得宣称所有
|
||||
跨 profile 误用都会在本地写入前被拦截。
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- `chat bot search` 只返回我创建的机器人,没有 `openDingTalkId`;给机器人发单聊必须用 `chat bot find`。
|
||||
- 机器人发群消息前需确认机器人已在群中;报“机器人不存在”时先 `group members add-bot`。
|
||||
- `send-by-bot` 支持 Markdown、图片 URL 和文件,具体参数见下方消息类型路由。
|
||||
- 机器人在群聊中引用回复已有消息时,使用原子命令 `send-by-bot --reply --ref-sender`;该能力仅支持 Markdown,不走 `+messages-reply` 的当前用户身份。
|
||||
- 公网图片 URL 使用 `--msg-type image --image-url`,按图片消息发送。
|
||||
- 本地图片和其他本地文件一样使用 `--msg-type file --file-path`,由 CLI 上传并按文件附件发送。
|
||||
- 群聊传 `--group`;单聊可传 `--users`、`--open-dingtalk-ids` 或两者组合。
|
||||
@@ -63,9 +64,10 @@ dws chat +messages-send --as bot --robot-code <robot-code> \
|
||||
|
||||
```bash
|
||||
# 群聊
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --msg-type image --image-url "https://example.com/image.png"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --msg-type file --file-path ./report.pdf
|
||||
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openConversationId> --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openConversationId> --reply <openMessageId> --ref-sender <senderOpenDingTalkId> --text "收到"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openConversationId> --msg-type image --image-url "https://example.com/image.png"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openConversationId> --msg-type file --file-path ./report.pdf
|
||||
|
||||
# 单聊 userId
|
||||
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
|
||||
@@ -84,16 +86,20 @@ dws chat message send-by-bot --robot-code <robot-code> --group <openConversation
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--robot-code` | 机器人 Code,必填 |
|
||||
| `--group` | 群聊 openConversationId |
|
||||
| `--conversation-id` | 群聊 openConversationId;`--group` 为兼容别名 |
|
||||
| `--users` | 单聊 userId 列表,逗号分隔,最多 20 个 |
|
||||
| `--open-dingtalk-ids` | 单聊 openDingTalkId 列表 |
|
||||
| `--msg-type` | `markdown`、`image` 或 `file`;省略时为 Markdown;公网图片使用 `image --image-url`,本地图片和文件使用 `file --file-path` |
|
||||
| `--text` | Markdown 消息内容,Markdown 模式必填;换行用空行,转义表示为 `\n\n` |
|
||||
| `--title` | Markdown 消息标题,Markdown 模式必填 |
|
||||
| `--title` | 普通 Markdown 消息标题;引用回复省略时由 CLI 从正文生成 |
|
||||
| `--image-url` | 公网图片 URL,`--msg-type image` 时必填 |
|
||||
| `--file-path` | 本地图片或文件路径,`--msg-type file` 时由 CLI 上传并按文件附件发送 |
|
||||
| `--at-user-ids` / `--at-open-dingtalk-ids` | 群聊 @ 指定成员,正文需含对应 `@id` 文本 |
|
||||
| `--at-all` | 群聊 @所有人 |
|
||||
| `--reply` | 被引用消息的 `openMessageId`;仅群聊 Markdown,必须与 `--ref-sender` 同时使用 |
|
||||
| `--ref-sender` | 被引用消息发送者的 `openDingTalkId`;仅群聊 Markdown,必须与 `--reply` 同时使用 |
|
||||
|
||||
引用回复不会设置 `msgType=reply`;CLI 在普通群消息参数顶层透传 `referenceOpenMessageId` 和 `srcMsgSendOpenDingTalkId`。只传其中一个参数、用于单聊或用于图片/文件消息都会在本地失败。
|
||||
|
||||
#### `dws chat message recall-by-bot`
|
||||
|
||||
@@ -176,5 +182,6 @@ dws chat +messages-send --as bot --robot-code <robot-code> --group <openConversa
|
||||
- 机器人单聊没有 openDingTalkId:改用 `chat bot find`,不要用 `bot search`。
|
||||
- 机器人发群消息报“机器人不存在”:先 `group members add-bot`。
|
||||
- 撤回失败:确认使用 `processQueryKey`,不是 `openMessageId`。
|
||||
- 机器人引用回复失败:确认目标是群聊 Markdown,且 `--reply` 来自被引用消息的 `openMessageId`、`--ref-sender` 来自同一消息的发送者 `openDingTalkId`。
|
||||
- @ 不生效:检查正文是否包含 `@userId` / `@openDingTalkId` / `@10`。
|
||||
- 需要 Bot 图片/文件/音视频:停止并说明当前身份矩阵不支持;只有用户明确同意改为当前用户身份时,才重新确认目标和内容。
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
- <!-- dws-intent: chat.conversation.list-top -->查看置顶会话默认使用 `dws chat +conversation-list-top`;
|
||||
原子 `list-top-conversations` 只在需要原始响应时作为 fallback。
|
||||
- <!-- dws-intent: chat.read.conversation -->取得目标会话后读取或导出消息记录,默认使用 `dws chat +chat-messages`;
|
||||
可附带非必填的 `--sender-query` 解析姓名:无稳定 ID 返回全部,唯一解析成功后按 `senderId` 筛选同一次读取结果。不要回到原子 `message list`,也不要补跑 `+search-msg`。直接条件检索优先使用 `+search-msg`。
|
||||
可附带非必填的 `--sender-query` 解析姓名:唯一解析成功后按 `senderId` 筛选同一次读取结果;
|
||||
解析失败、不完整或存在歧义时抑制未过滤消息并返回 `sender_resolution_failed`。不要回到原子
|
||||
`message list`,也不要补跑 `+search-msg`。直接条件检索优先使用 `+search-msg`。
|
||||
|
||||
## 必读约束
|
||||
|
||||
|
||||
@@ -30,10 +30,14 @@ dws chat +chat-messages --group <群名或openConversationId> --format json
|
||||
dws chat +chat-messages --group <openConversationId> --page-all --page-limit 50 --format json
|
||||
```
|
||||
|
||||
可附带非必填的 `--sender-query <姓名>`:未传时返回全部消息;未解析出稳定 ID 时保留
|
||||
全部消息并记录失败;唯一解析出 userId/openDingTalkId 后,按消息 `senderId` 筛选同一次
|
||||
读取结果,覆盖最终 `messages/count` 并返回 `resolvedFilters`。不得用展示名字符串比较否定
|
||||
已经成功解析的稳定发送者身份。
|
||||
可附带非必填的 `--sender-query <姓名>`:未传时返回全部消息;唯一解析出
|
||||
userId/openDingTalkId 后,按消息 `senderId` 筛选同一次读取结果,覆盖最终
|
||||
`messages/count` 并返回 `resolvedFilters`。解析失败、不完整或存在歧义时抑制未过滤消息,
|
||||
返回 `sender_resolution_failed`,不得把全量消息当作发送者筛选结果。
|
||||
|
||||
`--sender` 是姓名、userId 或 openDingTalkId 的混合入口。通讯录无法确认输入类型时,可按
|
||||
原值 userId 精确过滤并交付命中消息,但结果必须保留 `identity_unverified`,不得把字符串命中
|
||||
升级为已验证身份,也不得据此作完整否定结论。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group "项目群" --sender-query "测试用户甲" --page-all --format json
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 给姓名发简单文本 | `+dm` | 先查人再原子发送 |
|
||||
| 给群名发简单文本 | `+send-to-group` | 先搜群再原子发送 |
|
||||
| 文件、Bot、Webhook、复杂 @、幂等 | `+messages-send` | 为不同身份各走一套原子入口 |
|
||||
| 读取或导出指定会话,可附带发送者姓名 | `+chat-messages`;姓名用非必填 `--sender-query` | 无稳定 ID 返回全部,有则读后筛选;不补跑搜索 |
|
||||
| 读取或导出指定会话,可附带发送者姓名 | `+chat-messages`;姓名用非必填 `--sender-query` | 解析成功后读后筛选;解析失败抑制未过滤消息并返回错误;不补跑搜索 |
|
||||
| 直接按发送者、关键词、@对象或消息类型搜索 | `+search-msg` | 条件检索优先,可限定单个或跨多个会话 |
|
||||
| 已知消息 IDs 取详情 | `+messages-mget` | 重新搜索关键词 |
|
||||
| @我的消息 | `+at-me` | 全量消息后本地猜测 @ |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-doc
|
||||
description: 钉钉在线文字文档(adoc,「文档空间」里的在线文档)的查找、读取、创建、编辑、块、评论、附件、导入、导出(docx/markdown/pdf)、模板、版本、权限协作及 Markdown/JSONML 写入。文档空间与钉盘的文件管理走 dingtalk-drive(doc 同名原子命令已弃用),知识库空间与空间内节点走 dingtalk-wiki,原生 .md 文件读写走 dingtalk-misc。命令前缀:dws doc。
|
||||
description: 钉钉在线文字文档(adoc)本体及其内容的操作:查找、创建、读取、文档信息、编辑、块、评论、附件与媒体、白板卡片、导入、导出(docx/markdown/pdf)、版本、模板、权限、分享及Markdown/JSONML写入。不包括:文档空间与钉盘的文件管理(归 dingtalk-drive,doc 同名原子命令已弃用)、知识库空间与节点管理(归 dingtalk-wiki)、原生 .md 文件读写(归 dingtalk-misc)、电子表格 axls(归 dingtalk-misc)、AI 表格 able(归 dingtalk-aitable)。命令前缀:dws doc。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-drive
|
||||
description: 钉钉文件管理(存储层,覆盖钉盘与文档空间两个存储域)。Use when 用户说 钉盘/上传文件/下载文件/文件夹/查文件/找文件/全局搜索文件/复制/移动/重命名/删除/回收站/还原删除文件/权限管理/普通文件下载/本地与钉盘文件夹差异比较/整个文件夹拉到本地/整个文件夹推到钉盘/双向同步;也承接钉钉文档的这些管理动作(doc 侧同名原子命令已弃用)。文档正文编辑与导出 docx/markdown/pdf 走 dingtalk-doc,知识库空间与空间内节点组织走 dingtalk-wiki。命令前缀:dws drive。
|
||||
description: 钉钉文件管理(存储层,覆盖钉盘与文档空间两个存储域)。Use when 用户说 钉盘/上传文件/下载文件/文件夹/查文件/找文件/全局搜索文件/最近编辑的文档/复制/移动/重命名/删除/回收站/还原删除文件/文件权限管理/普通文件下载/本地与钉盘文件夹差异比较/整个文件夹拉到本地/整个文件夹推到钉盘/双向同步。对钉钉文档仅处理其作为文件的管理操作,文档本体及其内容操作归 dingtalk-doc;知识库空间与空间内节点组织归 dingtalk-wiki。命令前缀:dws drive。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -49,9 +49,9 @@ metadata:
|
||||
|
||||
### SOP-1 找文件(find-file)
|
||||
|
||||
**触发**:找文件/搜文件/我的文件/最近文件/某文档在哪。
|
||||
**触发**:找文件/搜文件/我的文件/最近文件/最近编辑的文档/某文档在哪。
|
||||
|
||||
1. **选源(必须)**:最近访问 → `dws drive +recent --limit <n> --format json`(翻页用上次返回的 `nextCursor` 传 `--cursor`);按内容/名称全局搜 → `dws drive +search --query "<关键词>" --format json`;浏览某目录 → `dws drive +list --folder <dentryUuid> --format json`。
|
||||
1. **选源(必须)**:最近访问 → `dws drive +recent --limit <n> --format json`(翻页用上次返回的 `nextCursor` 传 `--cursor`);最近编辑 → `dws drive +recent --operate-type 1 --limit <n> --format json`(不传 `--operate-type` 默认为 0 最近访问,会混入仅打开过的文档);按内容/名称全局搜 → `dws drive +search --query "<关键词>" --format json`;浏览某目录 → `dws drive +list --folder <dentryUuid> --format json`。
|
||||
2. **解析(必须)**:取真实 `dentryUuid`(= `id`/`nodeId`);多候选让用户确认,**禁止**默认取第一个。
|
||||
3. **下钻(必须)**:根目录没命中时,进入最相关文件夹继续 `drive +list --folder`,必要时 `python scripts/drive_tree_list.py --depth 2` 递归,**禁止**只看根目录就放弃。
|
||||
4. **回读元数据(必须)**:命中后 `dws drive +inspect --node <dentryUuid> --format json`,按 `extension` 确认类型。
|
||||
|
||||
@@ -26,15 +26,22 @@ metadata:
|
||||
|---|---|---|
|
||||
| `dws todo +assign` | write | 按姓名给某人创建并指派一条待办(自动解析 userId) |
|
||||
| `dws todo +assign-multi` | write | 把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId) |
|
||||
| `dws todo +comment` | write | 添加待办评论并读回验证 |
|
||||
| `dws todo +create` | write | 创建待办并读回验证 |
|
||||
| `dws todo +created-todos` | read | 列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的) |
|
||||
| `dws todo +due-today` | read | 列出我今天到期的待办 |
|
||||
| `dws todo +get` | read | 查询待办详情 |
|
||||
| `dws todo +get-my-tasks` | read | 查询当前组织下我的待办列表 |
|
||||
| `dws todo +get-related-tasks` | read | 一次性列出与我相关的全部待办(我作为创建人/执行人/参与人三种角色的并集,按 taskId 去重) |
|
||||
| `dws todo +list-attachment` | read | 查询待办任务的附件列表 |
|
||||
| `dws todo +list-comment` | read | 查询待办评论列表 |
|
||||
| `dws todo +list-sub` | read | 查询子待办列表 |
|
||||
| `dws todo +overdue` | read | 列出我已过期未完成的待办 |
|
||||
| `dws todo +remind` | write | 给自己创建一条带可选截止时间的待办 |
|
||||
| `dws todo +reminder` | write | 设置或清除待办提醒(仅终端回执) |
|
||||
| `dws todo +search` | read | 搜索与我相关的全部待办 |
|
||||
| `dws todo +todo-done` | write | 按标题关键词把我的某条待办标记完成(自动定位 taskId) |
|
||||
| `dws todo +update` | write | 更新待办并读回验证 |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
|
||||
Reference in New Issue
Block a user