Compare commits

...
Author SHA1 Message Date
Dennis bee1a91b74 fix(calendar): keep pagination out of result data 2026-08-18 00:41:34 +08:00
Dennis 92c80f81f9 fix(calendar): align attendee and agenda contracts 2026-08-17 21:30:31 +08:00
Dennis d245ea4c84 Merge remote-tracking branch 'origin/main' into codex/calendar-todo-shortcut-alignment 2026-08-17 20:30:27 +08:00
dxb 9e3a5d6fbd Merge pull request #1029 from DingTalk-Real-AI/fix/chat-sender-identity-contract
fix(chat): preserve unverified sender identity semantics
2026-08-17 19:10:30 +08:00
Dennis 33623d09d9 Merge remote-tracking branch 'origin/main' into codex/calendar-todo-shortcut-alignment 2026-08-17 18:48:47 +08:00
Dennis b20055a0b5 test(shortcut): close calendar todo coverage gaps 2026-08-17 18:48:39 +08:00
栩朝 fc05976d33 fix(chat): align chat message selection intent 2026-08-17 17:30:12 +08:00
栩朝 021da02474 fix(chat): preserve unverified sender identity semantics 2026-08-17 17:30:12 +08:00
github-actions[bot] a5b9e5a13f Merge pull request #928 from Anonymity-0/feat/bot-group-reply
feat(chat): support bot group message replies
2026-08-17 17:25:23 +08:00
Dennis 3dce49020e docs(shortcut): refresh integrated gate counts 2026-08-17 17:21:53 +08:00
Dennis fac92c252e Merge remote-tracking branch 'origin/main' into codex/calendar-todo-shortcut-alignment 2026-08-17 17:10:11 +08:00
Anonymity-0 9f8c525008 Merge branch 'main' into feat/bot-group-reply 2026-08-17 16:59:05 +08:00
github-actions[bot] 207d4dd7e5 Merge pull request #980 from cywan1998/feat/calendar-event-share-info
feat(calendar): add event share-info command
2026-08-17 08:57:50 +00:00
Dennis 8db297fe4b fix(calendar): preserve agenda schema compatibility 2026-08-17 16:53:07 +08:00
Dennis dc2aec7696 fix(calendar): preserve room-find flag compatibility 2026-08-17 16:44:06 +08:00
fengbai 9a6b7d4d41 Merge branch 'main' into feat/calendar-event-share-info 2026-08-17 16:41:08 +08:00
Dennis 404af112b7 fix(release): format shortcut change fragment 2026-08-17 16:09:19 +08:00
前津 5947016cc1 feat(chat): support bot group message replies 2026-08-17 16:09:08 +08:00
Dennis 5425d1565f feat(shortcut): align calendar and todo workflows 2026-08-17 16:01:14 +08:00
github-actions[bot] 386426bb92 Merge pull request #1012 from DingTalk-Real-AI/dws_0814_1723
fix(skill): update doc and drive descriptions for clearer routing
2026-08-17 07:45:22 +00:00
john 3cea671a54 Merge branch 'main' into dws_0814_1723 2026-08-17 15:27:17 +08:00
github-actions[bot] f06ea4d9e2 Merge pull request #960 from DingTalk-Real-AI/codex/doc-reread-audit
fix(doc): harden mutation readback verification
2026-08-17 07:06:53 +00:00
ruigong 93dbd768f7 fix(skill): add explicit recent-edited route to drive SOP-1 2026-08-17 14:18:03 +08:00
RuiGong01 03838a3430 Merge branch 'main' into dws_0814_1723 2026-08-17 11:39:56 +08:00
RuiGong01 0d34150333 Merge branch 'main' into dws_0814_1723 2026-08-17 11:16:56 +08:00
RuiGong01 9e8b58cbb6 Merge branch 'main' into dws_0814_1723 2026-08-17 10:47:33 +08:00
RuiGong01 fe724e96e8 Merge branch 'main' into dws_0814_1723 2026-08-17 10:39:02 +08:00
RuiGong01 f72979f4a9 Merge branch 'main' into dws_0814_1723 2026-08-17 09:41:16 +08:00
RuiGong01 0b012788c7 Merge branch 'main' into dws_0814_1723 2026-08-14 20:15:22 +08:00
chichuan 7b7bd556e9 Merge branch 'main' into feat/calendar-event-share-info 2026-08-14 17:43:49 +08:00
ruigong d534ee242c fix(skill): scope doc/drive descriptions to entity-content vs file management 2026-08-14 17:33:54 +08:00
fengbai 90473284b8 fix(calendar): remove shell comment from share-info example
- Move the eventId lookup hint into Long description
- Keep example commands free of shell comments to pass example policy gate
2026-08-14 15:51:20 +08:00
fengbai 07aa2c883a fix(calendar): address CR comments for share-info
- Fix Example indentation (tab -> 2 spaces)
- Remove unsubstantiated default en-US from --language help/docs
- Add test asserting calendarId/language are omitted when only --id is passed
2026-08-14 15:10:20 +08:00
fengbai 8aee08268d test(calendar): add event share-info dry-run and required-flag tests 2026-08-12 21:17:32 +08:00
fengbai 6a4744073c feat(calendar): add event share-info command 2026-08-12 21:07:59 +08:00
79 changed files with 7525 additions and 716 deletions
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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
+327 -64
View File
@@ -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",
+47
View File
@@ -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)
}
}
+14
View File
@@ -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) {
+82 -2
View File
@@ -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 (必填)")
+54 -8
View File
@@ -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) {
+303 -207
View File
@@ -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")
}
}
+490
View File
@@ -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": {},
+8
View File
@@ -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}
}
}
+24 -6
View File
@@ -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})
},
}
+22 -6
View File
@@ -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,
})
},
}
+88 -70
View File
@@ -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)
}
+472 -8
View File
@@ -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)
}
}
+25 -50
View File
@@ -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)
}
+6 -13
View File
@@ -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, "")
+22 -13
View File
@@ -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)
}
+9 -4
View File
@@ -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",
+32 -13
View File
@@ -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)
}
+4 -2
View File
@@ -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)
}
+5 -26
View File
@@ -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)
}
+49 -3
View File
@@ -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)
}
+7 -3
View File
@@ -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)
}
+15 -41
View File
@@ -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)
}
+8 -4
View File
@@ -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",
+30 -11
View File
@@ -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)
}
+23 -6
View File
@@ -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) {
+44 -3
View File
@@ -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)
}
+4 -11
View File
@@ -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)
}
+9 -37
View File
@@ -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)
}
+4 -2
View File
@@ -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)
}
+20 -5
View File
@@ -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})
},
}
+71 -5
View File
@@ -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")
}
}
+4 -2
View File
@@ -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)
}
+4 -2
View File
@@ -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)
}
+347
View File
@@ -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),
)
}
+459
View File
@@ -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
View File
@@ -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)
}
}
+659
View File
@@ -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())
+2
View File
@@ -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",
]
+2 -2
View File
@@ -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` 分页风格一致
+12 -7
View File
@@ -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>`
+1
View File
@@ -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` 分页风格一致
+1 -1
View File
@@ -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 -1
View File
@@ -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
+3 -3
View File
@@ -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` 确认类型。
+7
View File
@@ -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 -->
## 意图表