Compare commits

...
Author SHA1 Message Date
Dennis4477 be15dd05df Merge branch 'main' into codex/remove-calendar-todo-review-html 2026-08-18 20:26:11 +08:00
github-actions[bot] 3578e4019b Merge pull request #969 from wxianfeng/feat/85349380-primary-param-p0
feat: migrate first DWS Primary parameters with compatibility (#85349380)
2026-08-18 20:19:15 +08:00
wxianfeng 1ab8f113a5 test: close primary migration coverage gaps to #85349380 2026-08-18 19:25:17 +08:00
wxianfeng 4f4ea43549 fix: reconcile primary migration with current main #85349380 2026-08-18 19:25:17 +08:00
wxianfeng 4fd67c52dc docs: update primary parameter guidance to #85349380 2026-08-18 19:25:17 +08:00
wxianfeng a3b06befbc test: enforce primary parameter compatibility to #85349380 2026-08-18 19:25:17 +08:00
wxianfeng 290f39ecb8 feat: migrate doc and todo primary parameters to #85349380 2026-08-18 19:25:17 +08:00
wxianfeng 7fbe7593c8 feat: migrate chat primary parameters to #85349380 2026-08-18 19:25:17 +08:00
wxianfeng 9fb61f8e99 feat: migrate aisearch query primary to #85349380 2026-08-18 19:25:17 +08:00
github-actions[bot] 2287abe644 Merge pull request #1026 from Justper/oa_attachment_dws
add oa attachment dws
2026-08-18 19:24:33 +08:00
昭逸 32bd2118af Merge remote-tracking branch 'upstream/main' into oa_attachment_dws
to #666
2026-08-18 19:06:13 +08:00
昭逸 f290a2101e fix drive.md to #666 2026-08-18 19:06:01 +08:00
github-actions[bot] f26806bc55 Merge pull request #968 from wxianfeng/chore/85349380-primary-param-approval
chore: approve first Primary flag migrations (#85349380)
2026-08-18 18:27:07 +08:00
wxianfeng c53e1f465d ci: retain legacy Drive tree helper to #85349380 2026-08-18 17:23:33 +08:00
昭逸 8609963ef8 Merge remote-tracking branch 'upstream/main' into oa_attachment_dws
to #666
2026-08-18 17:03:46 +08:00
wxianfeng b334794168 chore: approve primary flag migrations to #85349380 2026-08-18 15:18:21 +08:00
github-actions[bot] 7186a69b78 Merge pull request #1035 from pengzhihan47-star/codex/aitabel_drive_opt
docs(skills): optimize drive and wiki routes
2026-08-18 13:28:13 +08:00
柏智 9c202c7eae docs(skills): restore compressed safety and space routes 2026-08-18 13:12:33 +08:00
柏智 2969fb3c21 docs(drive): align publish guard with runtime 2026-08-18 13:04:37 +08:00
柏智 149a2481f4 docs(drive): restore high-risk permission guards 2026-08-18 13:02:07 +08:00
柏智 4da2f382ec docs(drive): clarify commit unknown recovery 2026-08-18 12:43:20 +08:00
柏智 a3a96a6bd4 ci: retry cancelled coverage check 2026-08-18 12:38:09 +08:00
柏智 548809f72e Merge remote-tracking branch 'upstream/main' into codex/aitabel_drive_opt 2026-08-18 12:05:26 +08:00
github-actions[bot] 7568d05434 Merge pull request #1028 from yutongShe/feat/comment-p0-validation
feat: add Doc and Sheet comment lifecycle commands
2026-08-18 04:00:12 +00:00
柏智 6aaa15be3c docs(skills): clarify drive transfer evidence 2026-08-18 11:36:51 +08:00
yutongShe ac8e41aa5f Merge branch 'main' into feat/comment-p0-validation 2026-08-18 11:31:51 +08:00
柏智 57bc1bcea8 Merge remote-tracking branch 'upstream/main' into codex/aitabel_drive_opt 2026-08-18 11:28:43 +08:00
github-actions[bot] f1c5a887b6 Merge pull request #1008 from abucraft/codex/aitable-record-stats
feat(aitable): add server-side record statistics
2026-08-18 03:27:15 +00:00
昭逸 7c76e4fc03 test(oa): harden attachment delivery policy checks to #666 2026-08-18 11:23:51 +08:00
柏智 606f712a52 docs(skills): align wiki storage intent routes 2026-08-18 11:19:22 +08:00
柏智 dac4f6c029 docs(skills): fail closed on wiki space pagination 2026-08-18 11:15:11 +08:00
镜玄 b7a6abb780 ci: retry cancelled coverage supporting job 2026-08-18 11:07:16 +08:00
yutongShe 7dab8df861 Merge branch 'main' into feat/comment-p0-validation 2026-08-18 11:06:49 +08:00
昭逸 288212748c Merge branch 'oa_attachment_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_dws
to #666
2026-08-18 10:42:07 +08:00
昭逸 ef2c3ac163 Merge remote-tracking branch 'upstream/main' into oa_attachment_dws
to #666
2026-08-18 10:41:42 +08:00
柏智 b057c89a70 Merge remote-tracking branch 'upstream/main' into codex/aitabel_drive_opt 2026-08-18 10:41:37 +08:00
柏智 6ddfa59a28 docs(skills): fix wiki member verification example 2026-08-18 10:41:29 +08:00
昭逸 721a40b05e fix(oa): declare attachment result contracts
- add success and failure outcomes for three attachment commands
- define business data schemas and mark downloadUri as sensitive
- migrate attachment commands to unified result output
- verify compact and full Schema result projections
- cover success, malformed response, and tool error paths
to #666
2026-08-18 10:41:21 +08:00
Dennis d04511b8a6 Merge remote-tracking branch 'origin/main' into codex/remove-calendar-todo-review-html 2026-08-18 10:19:09 +08:00
李晟 f913c95ed1 Merge branch 'main' into codex/aitable-record-stats 2026-08-18 10:13:08 +08:00
github-actions[bot] effde76227 Merge pull request #1031 from DingTalk-Real-AI/fix/param-hallucination
feat(cli): expand AITable parameter alias normalization
2026-08-18 10:12:11 +08:00
李晟 43f0813acd Merge branch 'main' into codex/aitable-record-stats 2026-08-18 10:10:10 +08:00
柏智 33d8cd7e36 docs(skills): clarify drive copy routing 2026-08-18 10:08:01 +08:00
Dennis cfbe5b9b0d docs: remove calendar todo shortcut review 2026-08-18 09:54:21 +08:00
YanChangzhi 6e85983ad4 Merge branch 'main' into oa_attachment_dws 2026-08-18 09:53:09 +08:00
柏智 edbb175d4e docs(skills): optimize drive and wiki routes 2026-08-18 09:49:00 +08:00
克谨 b7bc0acb14 test(cli): cover AITable destructive alias gates 2026-08-18 09:44:42 +08:00
克谨 48e5d603bc Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-18 09:43:31 +08:00
github-actions[bot] 12ff9d6138 Merge pull request #1032 from DingTalk-Real-AI/codex/calendar-pagination-result-followup
fix(calendar): keep pagination out of result data
2026-08-18 01:15:35 +08:00
Dennis ea18feb0a8 fix(calendar): keep pagination out of result data 2026-08-18 00:50:23 +08:00
github-actions[bot] c5e3c2ec56 Merge pull request #1030 from DingTalk-Real-AI/codex/calendar-todo-shortcut-alignment
feat(shortcut): align Calendar and Todo workflows
2026-08-18 00:17:15 +08:00
Dennis 92c80f81f9 fix(calendar): align attendee and agenda contracts 2026-08-17 21:30:31 +08:00
克谨 70ed89c6bf test(cli): preserve AITable confirmation gates 2026-08-17 21:28:01 +08:00
克谨 07b14aa72a feat(cli): expand AITable parameter alias normalization 2026-08-17 20:40:30 +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
之桐 caf81b7984 feat(comments): add doc and sheet lifecycle commands 2026-08-17 17:50:29 +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
昭逸 5742239c74 Merge remote-tracking branch 'upstream/main' into oa_attachment_dws
to #666
2026-08-17 17:24:06 +08:00
Dennis 3dce49020e docs(shortcut): refresh integrated gate counts 2026-08-17 17:21:53 +08:00
李晟 4ec2635830 Merge branch 'main' into codex/aitable-record-stats 2026-08-17 17:18:31 +08:00
昭逸 f319906f29 fix(oa): close attachment coverage gaps to #666 2026-08-17 17:17:29 +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
李晟 1a58e3c3e6 Merge branch 'main' into codex/aitable-record-stats 2026-08-17 15:28:57 +08:00
john 3cea671a54 Merge branch 'main' into dws_0814_1723 2026-08-17 15:27:17 +08:00
镜玄 9d8b338833 fix(aitable): validate stats filters consistently 2026-08-17 15:21:45 +08:00
昭逸 ea92e0212b merge main to #666 2026-08-17 15:17:01 +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
昭逸 857279e076 将附件相关dws迁移到oa.go中,并补充skill描述 to #666 2026-08-17 14:03:42 +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
昭逸 3f2fc2e5f0 Merge remote-tracking branch 'upstream/main' into oa_attachment_dws
to #666
2026-08-14 17:56:53 +08:00
李晟 ef27877628 Merge branch 'main' into codex/aitable-record-stats 2026-08-14 17:47:44 +08:00
chichuan 7b7bd556e9 Merge branch 'main' into feat/calendar-event-share-info 2026-08-14 17:43:49 +08:00
昭逸 6a2e9dd10e 新增审批附件相关dws,预览授权、下载授权、获取下载链接 to #666 2026-08-14 17:41:02 +08:00
ruigong d534ee242c fix(skill): scope doc/drive descriptions to entity-content vs file management 2026-08-14 17:33:54 +08:00
镜玄 42b5004bf8 ci: retrigger pull request checks 2026-08-14 16:51:52 +08:00
镜玄 3a3cf00072 test(aitable): cover stats validation branches 2026-08-14 16:21:09 +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
镜玄 5abef59c7c feat(aitable): add server-side record statistics 2026-08-14 14:46:21 +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
202 changed files with 14685 additions and 3019 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: Changed
---
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
+9
View File
@@ -0,0 +1,9 @@
---
category: Added
---
- **AI Table server-side statistics** — adds `dws aitable record stats` for
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
record group-stats` for grouped, distinct, and advanced aggregation through
`query_stats`; both commands validate their JSON aggregation contracts before
dispatch.
+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.
+10
View File
@@ -0,0 +1,10 @@
---
category: Added
---
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
`comment resolve`, `comment restore`, and the lightweight
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
the same `doc-comment` MCP capabilities; batch queries preserve input order
for repeated `topicId:commentKey` references, while reaction replies require
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
+1 -1
View File
@@ -41,7 +41,7 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
## 两阶段迁移与回执清理
+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",
+1 -1
View File
@@ -102,7 +102,7 @@
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>wiki +wiki-new-doc --space &lt;名&gt;</code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +doc-append --doc --content</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +share-doc --to &lt;名&gt; --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
</tbody>
</table>
+4 -4
View File
@@ -56,13 +56,13 @@
| shortcut | 多步/智能逻辑 | 验证 |
|----------|--------------|------|
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
| `chat +dm --to <姓名> --content` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
| `chat +send-to-group --group <群名> --content` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
| `chat +broadcast --to <姓名CSV> --content` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
@@ -76,7 +76,7 @@
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
| `doc +doc-append --doc --content` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
@@ -0,0 +1,55 @@
# OA Attachment Download URL Output Design
## Goal
Keep the existing command and MCP request unchanged while making the returned
OSS signed URL directly copyable from JSON output:
```text
dws oa approval attachment download-url
```
## Scope
Only `oa approval attachment download-url` changes. The other OA attachment
commands and the global JSON formatter retain their current behavior.
## Design
The command continues to invoke MCP server `oa`, tool
`get_attachment_download_url`, with the same arguments. Its leaf declaration
provides a command-specific `Call` callback that invokes the existing MCP
dispatcher with HTML escaping disabled when the selected output format is
JSON. This preserves literal `&` separators in `result.downloadUri` instead of
rendering them as `\u0026`.
For `raw`, `table`, and other non-JSON formats, the callback uses the existing
escaped dispatcher behavior so their current rendering remains unchanged.
The change does not alter the URL, decode or re-sign it, download the file, or
change global JSON serialization.
## Error Handling
Authentication, MCP transport, gateway, PAT, and business errors continue
through the existing dispatcher and retain their current classification and
output behavior.
## Verification
Add a `TestCrossPlatformCoverage*` regression test that executes the real Cobra
leaf in explicit JSON mode with a fake MCP result containing a signed URL. It
must verify:
- the request still targets `oa/get_attachment_download_url`;
- the exact request arguments remain unchanged, including omission of the
optional boolean when the flag was not supplied;
- stdout contains literal `&OSSAccessKeyId=` and `&Signature=`;
- stdout contains no `\u0026` escape.
The fake caller must report JSON format (or the command must be executed with
`--format json`) so the test fails against the current escaped JSON path rather
than accidentally exercising raw MCP text output.
Run the focused OA attachment tests, format modified Go files, and rebuild the
CLI. No commit is created.
+2
View File
@@ -68,6 +68,8 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"revertedToVersion":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "list_workflows":
return `{"workflows":[]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "list_files":
@@ -27,12 +27,23 @@ const (
// its spelling while holding every other input constant.
var paramAliasCompleteCommands = map[string][]string{
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
"aitable +export-data": {"aitable", "+export-data", "--base-id", "base-1", "--scope", "all", "--format", "excel"},
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
"aitable +find-record": {"aitable", "+find-record", "--base", "base-1", "--table", "table-1", "--query", "fixture"},
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
"aitable +record-share-links": {"aitable", "+record-share-links", "--base", "base-1", "--table", "table-1", "--record-ids", "record-1"},
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
"aitable +workflow-list": {"aitable", "+workflow-list", "--base-id", "base-1", "--limit", "7"},
"aitable attachment upload": {"aitable", "attachment", "upload", "--base-id", "base-1", "--file-name", "fixture.txt", "--size", "7"},
"aitable base list": {"aitable", "base", "list", "--cursor", "cursor-1", "--limit", "7"},
"aitable base update": {"aitable", "base", "update", "--base-id", "base-1", "--name", "Fixture Base", "--desc", "fixture description"},
"aitable field search-options": {"aitable", "field", "search-options", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--keyword", "fixture", "--limit", "7"},
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
"aitable workflow get": {"aitable", "workflow", "get", "--base-id", "base-1", "--workflow-id", "workflow-1"},
"aitable workflow history": {"aitable", "workflow", "history", "--base-id", "base-1", "--workflow-id", "workflow-1", "--after-time", "1000", "--before-time", "2000", "--page", "2", "--size", "25"},
"aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
@@ -62,12 +73,12 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat +messages-reply": {"chat", "+messages-reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--content", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
@@ -93,11 +104,11 @@ var paramAliasCompleteCommands = map[string][]string{
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat message reply": {"chat", "message", "reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "fixture", "--at-users", "user-1,user-2", "--yes"},
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
@@ -116,7 +127,7 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--content", "fixture appendix", "--yes"},
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
@@ -132,8 +143,8 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--content", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--content", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
@@ -196,7 +207,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
},
"doc block insert": {
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--content", "fixture paragraph", "--yes"},
},
"doc +inspect": {
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
@@ -219,8 +230,8 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
},
"chat message send": {
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
"file": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
},
"chat +conversation-set-top": {
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
@@ -260,7 +271,6 @@ var paramAliasNewIMCases = []struct {
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat message send", emitted: "file", canonical: "file-path"},
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
@@ -278,7 +288,7 @@ var paramAliasNewIMCases = []struct {
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
{command: "chat +messages-list", emitted: "start", canonical: "time"},
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
{command: "chat +messages-reply", emitted: "chat", canonical: "group"},
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
@@ -372,16 +382,113 @@ var paramAliasNewDriveCases = []struct {
{command: "drive +upload", emitted: "file-id", canonical: "node"},
}
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
// every Drive command in the expansion whose declared runtime safety requires
// confirmation. The full matrix below proves all spellings preserve the
// confirmed payload; this smaller matrix proves aliases cannot cross the
// confirmation boundary before any transport call is made.
var paramAliasNewDriveConfirmationCases = []struct {
// paramAliasAITableDeleteDisableCompleteCommands contains complete invocations
// for every AITable delete/disable command whose confirmation boundary is
// reached by aliases introduced in the AITable expansion. These templates are
// intentionally separate from paramAliasCompleteCommands: that map mirrors
// the reviewed validation fixture one-for-one, while this matrix exhaustively
// proves the safety boundary for generated aliases beyond the fixture sample.
var paramAliasAITableDeleteDisableCompleteCommands = map[string][]string{
"aitable +advperm-disable": {"aitable", "+advperm-disable", "--base-id", "base-1", "--yes"},
"aitable +base-delete": {"aitable", "+base-delete", "--base-id", "base-1", "--yes"},
"aitable +chart-delete": {"aitable", "+chart-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
"aitable +dashboard-delete": {"aitable", "+dashboard-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
"aitable +field-delete": {"aitable", "+field-delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
"aitable +form-delete": {"aitable", "+form-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
"aitable +record-delete": {"aitable", "+record-delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
"aitable +role-delete": {"aitable", "+role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
"aitable +section-delete": {"aitable", "+section-delete", "--base-id", "base-1", "--section-id", "section-1", "--yes"},
"aitable +table-delete": {"aitable", "+table-delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
"aitable +view-delete": {"aitable", "+view-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
"aitable +workflow-disable": {"aitable", "+workflow-disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
"aitable advperm disable": {"aitable", "advperm", "disable", "--base-id", "base-1", "--yes"},
"aitable advperm role-delete": {"aitable", "advperm", "role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
"aitable base delete": {"aitable", "base", "delete", "--base-id", "base-1", "--yes"},
"aitable chart delete": {"aitable", "chart", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
"aitable dashboard delete": {"aitable", "dashboard", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
"aitable field delete": {"aitable", "field", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
"aitable form delete": {"aitable", "form", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
"aitable form questions delete": {"aitable", "form", "questions", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
"aitable record delete": {"aitable", "record", "delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
"aitable table delete": {"aitable", "table", "delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
"aitable view delete": {"aitable", "view", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
"aitable workflow disable": {"aitable", "workflow", "disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
}
// paramAliasNewAITableDeleteDisableCases is the exhaustive set of alias
// tuples newly introduced by this change on AITable delete/disable commands
// that require confirmation. Every tuple must remain on both sides of the
// confirmation gate: rejected with zero calls before --yes, and exactly
// payload-equivalent to its canonical spelling after --yes.
var paramAliasNewAITableDeleteDisableCases = []struct {
command string
emitted string
canonical string
}{
{command: "aitable +advperm-disable", emitted: "base", canonical: "base-id"},
{command: "aitable +advperm-disable", emitted: "base-token", canonical: "base-id"},
{command: "aitable +base-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +base-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +chart-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +chart-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +dashboard-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +dashboard-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +field-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +field-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +field-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +form-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +form-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +form-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +record-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +record-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +record-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +role-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +role-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +section-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +section-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +table-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +table-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +table-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +view-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +view-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +view-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +workflow-disable", emitted: "base", canonical: "base-id"},
{command: "aitable +workflow-disable", emitted: "base-token", canonical: "base-id"},
{command: "aitable +workflow-disable", emitted: "flow-id", canonical: "workflow-id"},
{command: "aitable advperm disable", emitted: "base-token", canonical: "base-id"},
{command: "aitable advperm role-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable base delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable chart delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable dashboard delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable field delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable field delete", emitted: "table", canonical: "table-id"},
{command: "aitable form delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable form delete", emitted: "table", canonical: "table-id"},
{command: "aitable form questions delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable form questions delete", emitted: "table", canonical: "table-id"},
{command: "aitable record delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable record delete", emitted: "table", canonical: "table-id"},
{command: "aitable table delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable table delete", emitted: "table", canonical: "table-id"},
{command: "aitable view delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable view delete", emitted: "table", canonical: "table-id"},
{command: "aitable workflow disable", emitted: "base-token", canonical: "base-id"},
{command: "aitable workflow disable", emitted: "flow-id", canonical: "workflow-id"},
}
// paramAliasNewConfirmationCases selects newly reviewed aliases for commands
// whose declared runtime safety requires confirmation. The full matrix below
// proves all spellings preserve the confirmed payload; this smaller matrix
// proves aliases cannot cross the confirmation boundary before any transport
// call is made.
var paramAliasNewConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "aitable workflow run", emitted: "base-token", canonical: "base-id"},
{command: "aitable workflow run", emitted: "flow-id", canonical: "workflow-id"},
{command: "aitable workflow run", emitted: "table", canonical: "table-id"},
{command: "drive +delete", emitted: "file-id", canonical: "node"},
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
@@ -402,7 +509,27 @@ var paramAliasNewDriveConfirmationCases = []struct {
// That duplicated command construction was enough to push the pre-existing
// macOS app suite beyond its package-level 10-minute timeout.
var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("aitable +export-data", "export-format"): true, // shortcut-local export format keeps the final payload
paramAliasPayloadCaseKey("aitable +find-record", "base-id"): true, // shortcut Base ID compatibility
paramAliasPayloadCaseKey("aitable +find-record", "table-id"): true, // shortcut Table ID compatibility
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("aitable +record-share-links", "base-id"): true, // observed experiment Base ID spelling
paramAliasPayloadCaseKey("aitable +record-share-links", "table-id"): true, // observed experiment Table ID spelling
paramAliasPayloadCaseKey("aitable +workflow-list", "max-results"): true, // shortcut pagination-size alias
paramAliasPayloadCaseKey("aitable attachment upload", "file-size"): true, // byte-size command override
paramAliasPayloadCaseKey("aitable base list", "next-cursor"): true, // cursor concept alias
paramAliasPayloadCaseKey("aitable base update", "description"): true, // plain description alias on a write command
paramAliasPayloadCaseKey("aitable field search-options", "query"): true, // search keyword concept alias
paramAliasPayloadCaseKey("aitable workflow get", "flow-id"): true, // workflow ID concept alias
paramAliasPayloadCaseKey("aitable workflow history", "base-token"): true, // Base ID concept alias
paramAliasPayloadCaseKey("aitable workflow history", "end-time"): true, // upper time-bound override
paramAliasPayloadCaseKey("aitable workflow history", "flow-id"): true, // workflow ID concept alias
paramAliasPayloadCaseKey("aitable workflow history", "page-index"): true, // zero-based page override
paramAliasPayloadCaseKey("aitable workflow history", "page-size"): true, // page-size concept alias
paramAliasPayloadCaseKey("aitable workflow history", "start-time"): true, // lower time-bound override
paramAliasPayloadCaseKey("aitable workflow run", "base-token"): true, // Base ID concept alias on a confirmed write
paramAliasPayloadCaseKey("aitable workflow run", "flow-id"): true, // workflow ID concept alias on a confirmed write
paramAliasPayloadCaseKey("aitable workflow run", "table"): true, // Table ID concept alias on a confirmed write
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
@@ -426,7 +553,11 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
paramAliasPayloadCaseKey("chat +messages-reply", "conversation-id"): true, // renamed conversation Primary keeps final reply payload
paramAliasPayloadCaseKey("chat message send", "file-path"): true, // renamed local-file Primary reaches the same final payload
paramAliasPayloadCaseKey("doc +doc-append", "text"): true, // shortcut content rename keeps append payload
paramAliasPayloadCaseKey("doc block insert", "text"): true, // block write content compatibility alias
paramAliasPayloadCaseKey("doc block update", "text"): true, // update uses the same typed compatibility path
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
@@ -629,13 +760,116 @@ func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalP
}
}
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewDriveConfirmationCases {
func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmationAndPayload(t *testing.T) {
coveredCommands := make(map[string]bool)
reviewedAliases := make(map[string]string, len(paramAliasNewAITableDeleteDisableCases))
for _, test := range paramAliasNewAITableDeleteDisableCases {
test := test
caseKey := paramAliasPayloadCaseKey(test.command, test.emitted)
if previous, duplicate := reviewedAliases[caseKey]; duplicate {
t.Fatalf("duplicate AITable delete/disable alias case %q: --%s and --%s", caseKey, previous, test.canonical)
}
reviewedAliases[caseKey] = test.canonical
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasAITableDeleteDisableCompleteCommands[test.command]
if !ok {
t.Fatal("reviewed AITable delete/disable alias has no complete safety template")
}
coveredCommands[test.command] = true
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
if replacements != 1 {
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
}
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
if removals != 1 {
t.Fatalf("safety template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
}
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active || target != test.canonical {
t.Fatalf("reviewed AITable alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
}
unconfirmedCaller := &paramAliasCaptureCaller{}
ctx, unconfirmedErr := executeParamAliasPayloadE2E(t, unconfirmedCaller, unconfirmedArgs...)
if ctx == nil {
t.Fatal("unconfirmed AITable alias command skipped PreParse")
}
var appErr *apperrors.Error
if !errors.As(unconfirmedErr, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("unconfirmed AITable alias command error = %#v, want confirmation_required\nargs=%v", unconfirmedErr, unconfirmedArgs)
}
if len(unconfirmedCaller.calls) != 0 {
t.Fatalf("unconfirmed AITable alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, unconfirmedCaller.calls)
}
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("confirmed canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("confirmed canonical command reached no final transport payload: args=%v", canonicalArgs)
}
aliasCaller := &paramAliasCaptureCaller{}
aliasCtx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil {
t.Fatalf("confirmed alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if aliasCtx == nil {
t.Fatal("confirmed AITable alias command skipped PreParse")
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("confirmed final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
for command := range paramAliasAITableDeleteDisableCompleteCommands {
if !coveredCommands[command] {
t.Errorf("AITable delete/disable safety template %q has no reviewed alias case", command)
}
}
if len(coveredCommands) != len(paramAliasAITableDeleteDisableCompleteCommands) {
t.Fatalf("AITable delete/disable safety coverage = %d commands, want %d", len(coveredCommands), len(paramAliasAITableDeleteDisableCompleteCommands))
}
activeAliases := 0
for command := range paramAliasAITableDeleteDisableCompleteCommands {
entry, exists := cli.LookupParamAlias(command)
if !exists {
t.Errorf("AITable delete/disable safety command %q has no generated alias entry", command)
continue
}
for emitted, canonical := range entry.Aliases {
activeAliases++
caseKey := paramAliasPayloadCaseKey(command, emitted)
reviewedCanonical, reviewed := reviewedAliases[caseKey]
if !reviewed {
t.Errorf("active AITable delete/disable alias %q --%s -> --%s has no confirmation/payload case", command, emitted, canonical)
continue
}
if reviewedCanonical != canonical {
t.Errorf("reviewed AITable delete/disable alias %q --%s target = --%s, generated --%s", command, emitted, reviewedCanonical, canonical)
}
}
}
if activeAliases != len(reviewedAliases) {
t.Fatalf("AITable delete/disable generated alias coverage = %d, want %d reviewed cases", activeAliases, len(reviewedAliases))
}
}
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewConfirmationCases {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
if !ok {
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
t.Fatal("reviewed confirmation alias has no complete-command E2E template")
}
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
if replacements != 1 {
@@ -649,7 +883,7 @@ func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *te
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active || target != test.canonical {
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
t.Fatalf("reviewed confirmation alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
}
caller := &paramAliasCaptureCaller{}
+49 -2
View File
@@ -264,7 +264,7 @@ func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *t
}
got := output.String() + "\n" + err.Error()
for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} {
for _, want := range []string{"已下线", "chat message send --msg-type file --file"} {
if !strings.Contains(got, want) {
t.Fatalf("chat media upload migration output missing %q:\n%s", want, got)
}
@@ -439,7 +439,7 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
}
got = got + "\n" + err.Error()
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file"} {
if !strings.Contains(got, want) {
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
}
@@ -462,6 +462,53 @@ func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
}
}
func TestCalendarEventShareInfoDryRunPreviewsOnly(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "share-info",
"--id", "EVT_001",
"--language", "zh-CN",
"--calendar-id", "primary",
})
if err != nil {
t.Fatalf("calendar event share-info --dry-run error = %v\n%s", err, got)
}
for _, want := range []string{"get_event_share_info", "eventId", "EVT_001", "zh-CN", "primary"} {
if !strings.Contains(got, want) {
t.Fatalf("calendar event share-info dry-run output missing %q:\n%s", want, got)
}
}
}
func TestCalendarEventShareInfoRequiresEventID(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "share-info",
})
if err == nil {
t.Fatalf("calendar event share-info without --id: expected error, got nil\n%s", got)
}
if strings.Contains(got, "\"executed\": true") {
t.Fatalf("share-info without --id must not execute:\n%s", got)
}
}
func TestCalendarEventShareInfoOmitsOptionalArgs(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "share-info",
"--id", "EVT_001",
})
if err != nil {
t.Fatalf("calendar event share-info --dry-run with only --id error = %v\n%s", err, got)
}
if !strings.Contains(got, "\"eventId\"") {
t.Fatalf("calendar event share-info dry-run output missing eventId:\n%s", got)
}
for _, unwanted := range []string{"\"calendarId\"", "\"language\""} {
if strings.Contains(got, unwanted) {
t.Fatalf("calendar event share-info dry-run with only --id should not contain %q:\n%s", unwanted, got)
}
}
}
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
root := NewRootCommand()
@@ -0,0 +1,49 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import "testing"
func TestCrossPlatformCoverageCalendarAgendaFinalSchemaPreservesCompositeProperties(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
tool := snapshot.Tools["calendar.shortcut_agenda"]
if tool == nil {
t.Fatal("calendar.shortcut_agenda is missing from final Schema")
}
parameters := schemaContractMap(tool["parameters"])
for flag, want := range map[string]string{
"start": "start",
"end": "end",
} {
parameter := parameters[flag]
if parameter == nil {
t.Fatalf("calendar.shortcut_agenda --%s is missing from final Schema", flag)
}
if got := schemaContractString(parameter["property"]); got != want {
t.Errorf("calendar.shortcut_agenda --%s property=%q, want %q", flag, got, want)
}
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Fatalf("calendar.shortcut_agenda interface_mode=%q, want composite", got)
}
result := schemaContractMap(tool["result"])
dataSchema := schemaContractMap(result["data_schema"])
properties := schemaContractMap(dataSchema["properties"])
for _, field := range []string{"hasMore", "nextCursor"} {
if _, exists := properties[field]; exists {
t.Fatalf("calendar.shortcut_agenda Result data_schema leaked pagination field %q", field)
}
}
if properties["complete"] == nil {
t.Fatal("calendar.shortcut_agenda Result data_schema is missing complete")
}
pagination, ok := tool["pagination"].(map[string]any)
if !ok {
t.Fatalf("calendar.shortcut_agenda pagination=%T, want object", tool["pagination"])
}
if got := schemaContractString(pagination["meta_path"]); got != "meta.pagination" {
t.Fatalf("calendar.shortcut_agenda pagination meta_path=%q, want meta.pagination", got)
}
}
+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) {
@@ -0,0 +1,166 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
tests := []struct {
cliPath string
canonical string
rpc string
description string
effect string
resultType string
resultFields map[string]string
sensitivePaths []string
}{
{
cliPath: "oa approval attachment download-url",
canonical: "oa.get_attachment_download_url",
rpc: "get_attachment_download_url",
description: "获取审批附件下载授权并生成临时下载链接",
effect: "read",
resultType: "object",
resultFields: map[string]string{
"spaceId": "integer", "agentId": "integer", "downloadUri": "string",
"class": "string", "fileId": "string",
},
sensitivePaths: []string{"downloadUri"},
},
{
cliPath: "oa approval attachment authorize-download",
canonical: "oa.auth_download_file",
rpc: "auth_download_file",
description: "批量授权当前用户下载指定的审批钉盘文件",
effect: "write",
resultType: "boolean",
},
{
cliPath: "oa approval attachment authorize-preview",
canonical: "oa.auth_preview_attachment",
rpc: "auth_preview_attachment",
description: "批量授权当前用户预览审批单中的附件",
effect: "write",
resultType: "object",
resultFields: map[string]string{
"spaceId": "integer", "agentId": "integer", "class": "string",
},
},
}
for _, test := range tests {
t.Run(test.rpc, func(t *testing.T) {
root := NewRootCommand()
command := exactCommandForTest(root, test.cliPath)
if command == nil {
t.Fatalf("executable command %q is missing", test.cliPath)
}
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetArgs([]string{"schema", test.cliPath, "--format", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("execute delivery schema leaf: %v; stderr=%s", err, stderr.String())
}
var tool map[string]any
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
t.Fatalf("decode delivery schema leaf: %v", err)
}
if got := schemaContractString(tool["canonical_path"]); got != test.canonical {
t.Fatalf("canonical_path = %q, want %q", got, test.canonical)
}
if got := schemaContractString(tool["primary_cli_path"]); got != test.cliPath {
t.Fatalf("primary_cli_path = %q, want %q", got, test.cliPath)
}
if got := schemaContractString(tool["description"]); !strings.HasPrefix(got, test.description) {
t.Fatalf("description = %q, want prefix %q", got, test.description)
}
if got := schemaContractString(tool["interface_mode"]); got != "mcp" {
t.Fatalf("interface_mode = %q, want mcp", got)
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Fatalf("availability = %q, want available", got)
}
interfaceRef := schemaInterfaceObject(tool["interface_ref"])
if got := schemaContractString(interfaceRef["product_id"]); got != "oa" {
t.Fatalf("interface_ref.product_id = %q, want oa", got)
}
if got := schemaContractString(interfaceRef["rpc_name"]); got != test.rpc {
t.Fatalf("interface_ref.rpc_name = %q, want %q", got, test.rpc)
}
if got := schemaContractString(tool["effect"]); got != test.effect {
t.Fatalf("effect = %q, want %q", got, test.effect)
}
if got := schemaContractString(tool["risk"]); got != "low" {
t.Fatalf("risk = %q, want low", got)
}
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
t.Fatalf("confirmation = %q, want not_required", got)
}
if got := schemaContractString(tool["idempotency"]); got != "idempotent" {
t.Fatalf("idempotency = %q, want idempotent", got)
}
fullResult := oaAttachmentResultContract(t, tool, test.resultType, test.resultFields, test.sensitivePaths)
stdout.Reset()
stderr.Reset()
root.SetArgs([]string{"schema", test.cliPath, "--compact", "--format", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("execute compact delivery schema leaf: %v; stderr=%s", err, stderr.String())
}
var compactTool map[string]any
if err := json.Unmarshal(stdout.Bytes(), &compactTool); err != nil {
t.Fatalf("decode compact delivery schema leaf: %v", err)
}
compactResult, ok := compactTool["result"].(map[string]any)
if !ok {
t.Fatalf("compact result = %#v, want object", compactTool["result"])
}
if !reflect.DeepEqual(compactResult, fullResult) {
t.Fatalf("compact/full result projection differs\ncompact: %#v\nfull: %#v", compactResult, fullResult)
}
if problem := schemaHelpFlagCompletenessProblem(test.canonical, test.cliPath, command, tool); problem != "" {
t.Fatal(problem)
}
})
}
}
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
t.Helper()
result, ok := tool["result"].(map[string]any)
if !ok {
t.Fatalf("full result = %#v, want object", tool["result"])
}
if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !reflect.DeepEqual(got, want) {
t.Fatalf("result.outcomes = %#v, want %#v", got, want)
}
if got := schemaContractStringSlice(result["sensitive_paths"]); !reflect.DeepEqual(got, sensitivePaths) {
t.Fatalf("result.sensitive_paths = %#v, want %#v", got, sensitivePaths)
}
dataSchema, ok := result["data_schema"].(map[string]any)
if !ok || schemaContractString(dataSchema["type"]) != resultType {
t.Fatalf("result.data_schema = %#v, want type %q", result["data_schema"], resultType)
}
properties, _ := dataSchema["properties"].(map[string]any)
if len(properties) != len(fields) {
t.Fatalf("result.data_schema.properties = %#v, want fields %#v", properties, fields)
}
for name, fieldType := range fields {
property, ok := properties[name].(map[string]any)
if !ok || schemaContractString(property["type"]) != fieldType {
t.Fatalf("result.data_schema.properties.%s = %#v, want type %q", name, properties[name], fieldType)
}
}
return result
}
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 418
publicShortcutCount = 434
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 420
schemaPublishedShortcutCount = 437
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 418
publiclyDeliveredShortcutCount = 434
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,242 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli
import "testing"
func TestPrimaryParamMigrationKeepsConceptDirectionCommandScoped(t *testing.T) {
concepts, err := LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
if got := concepts.ByConcept["content_text"].CanonicalHint; got != "text" {
t.Fatalf("content_text canonical hint = %q, want unchanged global hint text", got)
}
if got := concepts.ByConcept["open_conversation_id"].CanonicalHint; got != "conversation-id" {
t.Fatalf("open_conversation_id canonical hint = %q, want unchanged global hint conversation-id", got)
}
content := concepts.ByConcept["content_text"]
docNode := concepts.ByConcept["doc_node_id"]
docAppend, problems := reduceLeafParamAliases(
"doc +doc-append",
realMap(
realFlag{name: "content"},
realFlag{name: "text", hidden: true},
realFlag{name: "doc"},
),
[]Concept{content, docNode},
CommandOverride{},
)
if len(problems) != 0 {
t.Fatalf("doc +doc-append reduction problems = %v", problems)
}
assertPrimaryParamAlias(t, docAppend, "body", "content")
assertPrimaryParamAlias(t, docAppend, "node", "doc")
insertOverride := primaryParamOverride(t, concepts, "doc block insert")
docInsert, problems := reduceLeafParamAliases(
"doc block insert",
realMap(
realFlag{name: "content"},
realFlag{name: "text", hidden: true},
realFlag{name: "parent-block"},
realFlag{name: "ref-block"},
),
[]Concept{content},
insertOverride,
)
if len(problems) != 0 {
t.Fatalf("doc block insert reduction problems = %v", problems)
}
assertPrimaryParamAlias(t, docInsert, "body", "content")
docUpdate, problems := reduceLeafParamAliases(
"doc block update",
realMap(realFlag{name: "content"}, realFlag{name: "text", hidden: true}),
[]Concept{content},
CommandOverride{},
)
if len(problems) != 0 {
t.Fatalf("doc block update reduction problems = %v", problems)
}
assertPrimaryParamAlias(t, docUpdate, "body", "content")
replyOverride := primaryParamOverride(t, concepts, "chat +messages-reply")
chatReply, problems := reduceLeafParamAliases(
"chat +messages-reply",
realMap(
realFlag{name: "group"},
realFlag{name: "conversation-id", hidden: true},
realFlag{name: "ref-msg-id"},
),
[]Concept{concepts.ByConcept["open_conversation_id"]},
replyOverride,
)
if len(problems) != 0 {
t.Fatalf("chat +messages-reply reduction problems = %v", problems)
}
for _, emitted := range []string{"chat", "chat-id", "open-conversation-id"} {
assertPrimaryParamAlias(t, chatReply, emitted, "group")
}
assertPrimaryParamAlias(t, chatReply, "msg-id", "ref-msg-id")
if chatReply.IsBlocked("group") {
t.Fatal("chat +messages-reply canonical --group must not remain blocked")
}
}
func TestPrimaryParamMigrationUpdatesReviewedFixturesAndMappingKeys(t *testing.T) {
concepts, err := LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
fixtures := []ParamFixtureCase{
{Command: "doc +doc-append", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias"},
{Command: "doc +doc-append", Emitted: "node", Expect: "doc", Via: "concept:doc_node_id"},
{Command: "doc block insert", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 2},
{Command: "doc block update", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 6},
{Command: "chat message send", Emitted: "file-path", Expect: "file", Via: "native:reviewed-compatibility-alias"},
{Command: "chat +messages-reply", Emitted: "conversation-id", Expect: "group", Via: "native:reviewed-compatibility-alias"},
{Command: "chat +messages-reply", Emitted: "chat", Expect: "group", Via: "concept:open_conversation_id"},
{Command: "chat +messages-reply", Emitted: "open-conversation-id", Expect: "group", Via: "concept:open_conversation_id"},
}
for _, want := range fixtures {
found := false
for _, got := range concepts.Fixture {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("reviewed fixture missing %#v", want)
}
}
tests := []struct {
oldKey string
newKey string
reason string
}{
{"chat.reply_personal_message --text", "chat.reply_personal_message --content", "serialized into the aggregate content JSON string"},
{"chat.send_personal_message --file-path", "chat.send_personal_message --file", "local upload/preprocessing input"},
{"chat.send_personal_message --text", "chat.send_personal_message --content", "serialized into the aggregate content payload"},
{"doc.insert_document_block --text", "doc.insert_document_block --content", "aggregate convenience input used to build element"},
{"doc.update_document_block --text", "doc.update_document_block --content", "aggregate convenience input used to build element"},
{"todo.add_todo_attachment --file-path", "todo.add_todo_attachment --file", "local upload input used to construct attachmentList"},
}
for _, test := range tests {
if _, exists := reviewedSchemaParameterMappingExclusions[test.oldKey]; exists {
t.Errorf("stale mapping exclusion remains at %q", test.oldKey)
}
if got := reviewedSchemaParameterMappingExclusions[test.newKey]; got != test.reason {
t.Errorf("mapping exclusion %q reason = %q, want %q", test.newKey, got, test.reason)
}
}
}
func TestPrimaryParamMigrationPreservesDeliverySchemaSignatures(t *testing.T) {
tests := []struct {
path string
primary string
legacy string
required bool
cliRequired bool
property string
propertyFrom string
format string
requiredWhen string
requiredWhenFrom string
}{
{path: "aisearch person", primary: "query", legacy: "keyword", required: true, property: "keyword", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat message reply", primary: "content", legacy: "text", required: true, cliRequired: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
{path: "chat message send", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
{path: "chat message send", primary: "file", legacy: "file-path", propertyFrom: "reviewed_mapping_exclusion", format: "file-path", requiredWhen: "msg-type is file or audio or video", requiredWhenFrom: "typed_parameter_metadata"},
{path: "chat message send-by-webhook", primary: "content", legacy: "text", required: true, cliRequired: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +broadcast", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +dm", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +send-to-group", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-batch-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-send-by-webhook", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-reply", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-reply", primary: "group", legacy: "conversation-id", required: true, property: "conversationId", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "doc +doc-append", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "doc block insert", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
{path: "doc block update", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
{path: "todo task add-attachment", primary: "file", legacy: "file-path", required: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
}
for _, test := range tests {
t.Run(test.path+"/"+test.primary, func(t *testing.T) {
leaf, err := queryDeliverySchemaPayload([]string{test.path})
if err != nil {
t.Fatal(err)
}
parameters := schemaMap(leaf["parameters"])
parameter, exists := parameters[test.primary]
if !exists {
t.Fatalf("%s missing visible Primary --%s", test.path, test.primary)
}
if _, exists := parameters[test.legacy]; exists {
t.Fatalf("%s unexpectedly publishes hidden legacy --%s", test.path, test.legacy)
}
if got := parameter["type"]; got != "string" {
t.Errorf("%s --%s type = %#v, want string", test.path, test.primary, got)
}
if got := parameter["required"]; got != test.required {
t.Errorf("%s --%s required = %#v, want %v", test.path, test.primary, got, test.required)
}
if got, _ := parameter["cli_required"].(bool); got != test.cliRequired {
t.Errorf("%s --%s cli_required = %#v, want %v", test.path, test.primary, parameter["cli_required"], test.cliRequired)
}
if got, _ := parameter["property"].(string); got != test.property {
t.Errorf("%s --%s property = %q, want %q", test.path, test.primary, got, test.property)
}
if got := parameter["interface_type"]; got != nil && got != "" {
t.Errorf("%s --%s interface_type = %#v, want omitted/empty", test.path, test.primary, got)
}
if got, _ := parameter["format"].(string); got != test.format {
t.Errorf("%s --%s format = %q, want %q", test.path, test.primary, got, test.format)
}
if got, _ := parameter["required_when"].(string); got != test.requiredWhen {
t.Errorf("%s --%s required_when = %q, want %q", test.path, test.primary, got, test.requiredWhen)
}
provenance := schemaMap(parameter["field_provenance"])
propertyProvenance := provenance["property"]
if got := propertyProvenance["source"]; got != test.propertyFrom {
t.Errorf("%s --%s property source = %#v, want %s", test.path, test.primary, got, test.propertyFrom)
}
typeProvenance := provenance["type"]
if got := typeProvenance["source"]; got != "cobra_flag_type" {
t.Errorf("%s --%s type source = %#v, want cobra_flag_type", test.path, test.primary, got)
}
requiredWhenProvenance := provenance["required_when"]
if got := requiredWhenProvenance["source"]; got != test.requiredWhenFrom {
t.Errorf("%s --%s required_when source = %#v, want %s", test.path, test.primary, got, test.requiredWhenFrom)
}
})
}
}
func primaryParamOverride(t *testing.T, concepts ParamConcepts, path string) CommandOverride {
t.Helper()
for _, override := range concepts.Overrides {
if override.CommandPath == path {
return override
}
}
t.Fatalf("command override %q not found", path)
return CommandOverride{}
}
func assertPrimaryParamAlias(t *testing.T, entry *ParamAliasEntry, emitted, want string) {
t.Helper()
if entry == nil {
t.Fatalf("alias entry is nil; want %s -> %s", emitted, want)
}
if got, ok := entry.ResolveAlias(emitted); !ok || got != want {
t.Fatalf("ResolveAlias(%q) = %q (ok=%v), want %q", emitted, got, ok, want)
}
}
+2 -2
View File
@@ -68,9 +68,9 @@ func init() {
registerRequireOneOf("devdoc.search_open_platform_docs_rag", "query", "keyword")
registerRequireOneOf("event.consume", "event_key", "subscribe-id")
registerExclusiveOneOf("event.stop", "all", "subscribe_id")
registerRequireOneOf("doc.insert_document_block", "text", "heading", "element")
registerRequireOneOf("doc.insert_document_block", "content", "heading", "element")
registerExclusiveOneOf("doc.update_document", "content", "content-file")
registerRequireOneOf("doc.update_document_block", "text", "heading", "element")
registerRequireOneOf("doc.update_document_block", "content", "heading", "element")
registerRequireOneOf("pat.batch_grant", "scope", "product", "products", "domain", "domains", "recommend")
registerRequireOneOf("mail.search_mail_users", "keyword", "employee-no")
// --body is a hidden compatibility alias for the public --content flag.
@@ -269,20 +269,20 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
"chat.reply_personal_message --content": "serialized into the aggregate content JSON string",
"chat.search_groups --cursor": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"chat.search_groups --exclude-muted": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --exclude-muted is a CLI wrapper input and does not publish a direct interface property.",
"chat.search_groups --limit": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"chat.search_messages --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.search_messages --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.send_personal_message --contact-id": "serialized into the aggregate content payload",
"chat.send_personal_message --file-path": "local upload/preprocessing input",
"chat.send_personal_message --file": "local upload/preprocessing input",
"chat.send_personal_message --latitude": "serialized into the aggregate content payload",
"chat.send_personal_message --location-name": "serialized into the aggregate content payload",
"chat.send_personal_message --longitude": "serialized into the aggregate content payload",
"chat.send_personal_message --map-thumbnail-url": "serialized into the aggregate content payload",
"chat.send_personal_message --media-id": "serialized into the aggregate content payload",
"chat.send_personal_message --text": "serialized into the aggregate content payload",
"chat.send_personal_message --content": "serialized into the aggregate content payload",
"chat.send_personal_message --title": "serialized into the aggregate content payload",
"chat.send_personal_message --user": "resolves a userId to receiverOpenDingTalkId when possible and otherwise uses the unpinned receiverUserId compatibility property",
"chat.set_group_member_mute_list --user": "conditional wrapper/alias of --users: userIds are remotely resolved to openDingTalkIds outside dry-run and otherwise may be sent as uids; openDingTalkId inputs are sent as openDingTalkIds, so one exact property would misdescribe execution",
@@ -406,7 +406,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
"doc.insert_document_block --level": "aggregate convenience input used to build element",
"doc.insert_document_block --text": "aggregate convenience input used to build element",
"doc.insert_document_block --content": "aggregate convenience input used to build element",
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
@@ -438,7 +438,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.update_document_block --fix-jsonml": "local JSONML normalization control",
"doc.update_document_block --heading": "aggregate convenience input used to build element",
"doc.update_document_block --level": "aggregate convenience input used to build element",
"doc.update_document_block --text": "aggregate convenience input used to build element",
"doc.update_document_block --content": "aggregate convenience input used to build element",
"doc.version_list --cursor": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"doc.version_list --limit": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"doc.version_list --node": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
@@ -659,7 +659,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.write_image --file": "local upload input used to obtain resourceId/resourceUrl",
"sheet.write_image --mime-type": "local upload metadata",
"sheet.write_image --name": "local upload metadata",
"todo.add_todo_attachment --file-path": "local upload input used to construct attachmentList",
"todo.add_todo_attachment --file": "local upload input used to construct attachmentList",
"todo.get_user_todos_in_current_org --query-all": "Local route selector: the default path calls get_user_todos_in_current_org, while --query-all switches to get_user_todos; it is not a property of the pinned default RPC.",
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
+3 -3
View File
@@ -77,7 +77,7 @@ func cloneRuntimeSchemaStringMap(source map[string]string) map[string]string {
func init() {
RegisterRuntimeSchemaParameterMetadata("aisearch.enterprise_person_search", RuntimeSchemaParameterMetadata{
Required: []string{"keyword"},
Required: []string{"query"},
})
RegisterRuntimeSchemaParameterMetadata("aitable.export_data", RuntimeSchemaParameterMetadata{
RequiredWhen: map[string]string{
@@ -146,8 +146,8 @@ func init() {
})
RegisterRuntimeSchemaParameterMetadata("chat.send_personal_message", RuntimeSchemaParameterMetadata{
RequiredWhen: map[string]string{
"media-id": "msg-type is image",
"file-path": "msg-type is file or audio or video",
"media-id": "msg-type is image",
"file": "msg-type is file or audio or video",
},
Examples: map[string]string{"media-id": "@lADP_schema_smoke"},
})
+47 -31
View File
@@ -3,18 +3,21 @@ package helpers
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// boolPtr returns a pointer to v, for ParamDecl.Required declarations.
func boolPtr(v bool) *bool { return &v }
// aisearchKeywordAliases 是 --keyword flag 的同义瞎猜兜底列表。
// 模型可能写 --name / --q / --query / --text,这些都被识别为 keyword。
var aisearchKeywordAliases = []string{"name", "q", "query", "text"}
// 这些是原有的宽松兜底名,不属于本次 Primary 迁移;保持原生 hidden
// 注册,不给它们新增 alias_of/origin 元数据。
var aisearchPersonGuessFlags = []string{"name", "q", "text"}
// 历史解析优先级必须独立于新旧 Primary:双传时旧 --keyword 始终获胜。
var aisearchQueryResolutionOrder = []string{"keyword", "name", "q", "query", "text"}
// flagValue 安全地读取 flag 值:先查 local,再查自身 PersistentFlags,
// 再查 parents 的 PersistentFlags。比 cmd.Flags().GetString 更鲁棒,
@@ -58,14 +61,11 @@ func aisearchFlagOrDefault(cmd *cobra.Command, primary, def string, aliases ...s
return def
}
// resolveAisearchKeyword 从命令的 flag 中解析 keyword:优先 --keyword,
// 否则 fallback 到 aisearchKeywordAliases 中的任一同义 flag。
// resolveAisearchKeyword 按历史顺序解析人员搜索词。虽然公开 Primary 已改为
// --query,但兼容期内显式旧 --keyword 的值仍优先。
func resolveAisearchKeyword(cmd *cobra.Command) string {
if v := flagValue(cmd, "keyword"); v != "" {
return v
}
for _, alias := range aisearchKeywordAliases {
if v := flagValue(cmd, alias); v != "" {
for _, name := range aisearchQueryResolutionOrder {
if v := flagValue(cmd, name); v != "" {
return v
}
}
@@ -73,9 +73,23 @@ func resolveAisearchKeyword(cmd *cobra.Command) string {
}
func addAisearchPersonFlags(cmd *cobra.Command) {
cmd.Flags().StringP("keyword", "w", "", "搜索关键词 (必填,如人名、技能关键词等)")
// Alias evidence must be emitted by corecmd.FlagSpec.Aliases. Build the pair
// on a temporary declaration command so the legacy alias can retain its
// historical -w shorthand without extending the global alias mechanism or
// assigning that shorthand to the new --query Primary.
declaration := &cobra.Command{Use: "aisearch-query-flags"}
corecmd.RegisterFlags(declaration, []corecmd.FlagSpec{{
Name: "query",
Usage: "搜索关键词 (必填,如人名、技能关键词等)",
Aliases: []string{"keyword"},
}})
queryFlag := declaration.Flags().Lookup("query")
keywordFlag := declaration.Flags().Lookup("keyword")
keywordFlag.Shorthand = "w"
cmd.Flags().AddFlag(queryFlag)
cmd.Flags().AddFlag(keywordFlag)
cmd.Flags().StringP("dimension", "d", "all", "查询维度: all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,多个用逗号分隔")
for _, alias := range aisearchKeywordAliases {
for _, alias := range aisearchPersonGuessFlags {
cmd.Flags().String(alias, "", "")
_ = cmd.Flags().MarkHidden(alias)
}
@@ -98,8 +112,7 @@ func runAisearchPerson(cmd *cobra.Command, _ []string) error {
}
keyword := resolveAisearchKeyword(cmd)
if keyword == "" {
// 复用原有报错文案("keyword is required")
return validateRequiredFlags(cmd, "keyword")
return validateRequiredFlags(cmd, "query")
}
dimensions := parseDimensions(flagValue(cmd, "dimension"))
return callMCPTool("enterprise_person_search", map[string]any{
@@ -188,8 +201,8 @@ func newAisearchCommand() *cobra.Command {
Use: "aisearch",
Short: "AI 搜问",
Long: `AI 搜问:搜索企业人员信息、企业内部知识内容与企业内部行为记录。`,
// 智能 root:模型常漏 person 子命令直接 dws aisearch --keyword xxx,
// 检测到 keyword 就自动等价于 person;否则退回 group help。
// 智能 root:模型常漏 person 子命令直接 dws aisearch --query xxx,
// 检测到 query 就自动等价于 person;否则退回 group help。
RunE: func(cmd *cobra.Command, args []string) error {
if resolveAisearchKeyword(cmd) != "" {
return runAisearchPerson(cmd, args)
@@ -199,9 +212,9 @@ func newAisearchCommand() *cobra.Command {
}
// root 和 person 各自定义同一组本地 flag,这样:
// - dws aisearch --keyword xxx ← root 自己能解析
// - dws aisearch person --keyword xxx ← person 本地 flag
// - dws aisearch search --keyword xxx ← search 是 person 的 alias
// - dws aisearch --query xxx ← root 自己能解析
// - dws aisearch person --query xxx ← person 本地 flag
// - dws aisearch search --query xxx ← search 是 person 的 alias
// 不能放在 product PersistentFlags:否则 enterprise/behavior 会公开接受
// 它们不拥有的 person-only dimension,破坏 Help ↔ Schema 完整性。
addAisearchPersonFlags(root)
@@ -219,7 +232,7 @@ func newAisearchCommand() *cobra.Command {
"query", "people", "ask", "find", "lookup",
"contact",
},
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --keyword xxx,
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --query xxx,
// 此时 "search" 会作为 positional arg 被忽略,不报错。
Args: cobra.ArbitraryArgs,
Short: "搜索企业人员",
@@ -237,13 +250,13 @@ func newAisearchCommand() *cobra.Command {
jobNumber 工号
多个维度用逗号分隔。`,
Example: ` dws aisearch person --keyword "张三" --dimension department
dws aisearch person --keyword "产品部" --dimension department
dws aisearch person --keyword "五道" --dimension supervisor
dws aisearch person --keyword "AI搜问" --dimension duty
dws aisearch person --keyword "李四" --dimension name,department
dws aisearch person --keyword "13800138000" --dimension phone
dws aisearch person --keyword "W12345" --dimension jobNumber`,
Example: ` dws aisearch person --query "张三" --dimension department
dws aisearch person --query "产品部" --dimension department
dws aisearch person --query "五道" --dimension supervisor
dws aisearch person --query "AI搜问" --dimension duty
dws aisearch person --query "李四" --dimension name,department
dws aisearch person --query "13800138000" --dimension phone
dws aisearch person --query "W12345" --dimension jobNumber`,
RunE: runAisearchPerson,
}
DeclareLeafMetadata(personCmd, LeafSpec{
@@ -277,10 +290,13 @@ func newAisearchCommand() *cobra.Command {
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior",
},
Examples: []string{
"dws aisearch person --keyword \"张三\" --dimension name --format json",
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json",
"dws aisearch person --query \"张三\" --dimension name --format json",
"dws aisearch person --query \"五道\" --dimension supervisor --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "query", Property: "keyword", Required: boolPtr(true)},
},
},
})
addAisearchPersonFlags(personCmd)
+64 -2
View File
@@ -4,7 +4,12 @@
package helpers
import "testing"
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
)
func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
root := newAisearchCommand()
@@ -12,12 +17,34 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"keyword", "dimension"} {
for _, name := range []string{"query", "dimension"} {
flag := person.Flags().Lookup(name)
if flag == nil || flag.Hidden {
t.Fatalf("person --%s = %#v, want visible local flag", name, flag)
}
}
if person.Flags().Lookup("query").Shorthand != "" {
t.Fatal("new Primary --query must not acquire a shorthand")
}
keyword := person.Flags().Lookup("keyword")
if keyword == nil || !keyword.Hidden || keyword.Shorthand != "w" {
t.Fatalf("person --keyword = %#v, want hidden legacy flag with -w", keyword)
}
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "query" {
t.Fatalf("person --keyword alias_of = %#v, want query", got)
}
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("person --keyword alias_origin = %#v, want %q", got, runtimeannotate.FlagAliasOriginCorecmdV1)
}
for _, name := range []string{"name", "q", "text"} {
flag := person.Flags().Lookup(name)
if flag == nil || !flag.Hidden {
t.Fatalf("person --%s = %#v, want preserved hidden guess flag", name, flag)
}
if got := flag.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 0 {
t.Fatalf("person --%s unexpectedly gained alias_of metadata: %#v", name, got)
}
}
for _, path := range []string{"enterprise", "behavior"} {
cmd, _, err := root.Find([]string{path})
@@ -35,3 +62,38 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
}
}
}
func TestAisearchPersonQueryPrimaryCompatibility(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "root new", args: []string{"--query", "new"}, want: "new"},
{name: "root old", args: []string{"--keyword", "old"}, want: "old"},
{name: "root old shorthand", args: []string{"-w", "old-short"}, want: "old-short"},
{name: "root both old wins", args: []string{"--query", "new", "--keyword", "old"}, want: "old"},
{name: "person new", args: []string{"person", "--query", "new"}, want: "new"},
{name: "person old", args: []string{"person", "--keyword", "old"}, want: "old"},
{name: "person old shorthand", args: []string{"person", "-w", "old-short"}, want: "old-short"},
{name: "person both old wins", args: []string{"person", "--query", "new", "--keyword", "old"}, want: "old"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
if err := executeFilterCoverage(t, newAisearchCommand(), tc.args...); err != nil {
t.Fatal(err)
}
if caller.tool != "enterprise_person_search" || caller.args["keyword"] != tc.want {
t.Fatalf("call = %s %#v, want backend keyword %q", caller.tool, caller.args, tc.want)
}
})
}
installScriptedCaller(t, &scriptedToolCaller{})
err := executeFilterCoverage(t, newAisearchCommand(), "person")
if err == nil || !strings.Contains(err.Error(), "--query") || strings.Contains(err.Error(), "--keyword") {
t.Fatalf("missing query error = %v, want only new Primary", err)
}
}
+308 -2
View File
@@ -520,6 +520,102 @@ func normalizeFilters(parsed any) any {
}
}
type aitableStatsItem struct {
FieldID string `json:"fieldId"`
StatsType string `json:"statsType"`
}
func aitableStatsValidationf(format string, args ...any) error {
return apperrors.NewValidation(fmt.Sprintf(format, args...))
}
func parseAitableStatsItems(raw string, uppercase, uniqueFields bool, maxItems int) ([]map[string]any, error) {
var items []aitableStatsItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, aitableStatsValidationf("--stats 必须是 JSON 数组: %v", err)
}
var strictItems []aitableStatsItem
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&strictItems); err != nil {
return nil, aitableStatsValidationf("--stats 包含不支持的字段: %v", err)
}
items = strictItems
if len(items) == 0 {
return nil, aitableStatsValidationf("--stats 至少需要一个统计项")
}
if maxItems > 0 && len(items) > maxItems {
return nil, aitableStatsValidationf("--stats 单次最多支持 %d 个统计项,got %d", maxItems, len(items))
}
seenFields := make(map[string]struct{}, len(items))
out := make([]map[string]any, 0, len(items))
for index, item := range items {
item.FieldID = strings.TrimSpace(item.FieldID)
item.StatsType = strings.TrimSpace(item.StatsType)
if item.FieldID == "" || item.StatsType == "" {
return nil, aitableStatsValidationf("--stats[%d] 的 fieldId 和 statsType 均不能为空", index)
}
if uniqueFields {
if _, exists := seenFields[item.FieldID]; exists {
return nil, aitableStatsValidationf("--stats 中 fieldId %q 重复;query_records_stats 要求同一字段的多个统计类型拆成多次调用", item.FieldID)
}
seenFields[item.FieldID] = struct{}{}
}
if uppercase && item.StatsType != strings.ToUpper(item.StatsType) {
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用大写枚举值,got %q", index, item.StatsType)
}
if !uppercase && item.StatsType != strings.ToLower(item.StatsType) {
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用小写值,got %q", index, item.StatsType)
}
out = append(out, map[string]any{"fieldId": item.FieldID, "statsType": item.StatsType})
}
return out, nil
}
func parseAitableObjectFlag(name, raw string) (map[string]any, error) {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
var value map[string]any
if err := decoder.Decode(&value); err != nil {
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象: %v", name, err)
}
if value == nil {
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象,不能是 null", name)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return nil, aitableStatsValidationf("--%s 只能包含一个 JSON 对象", name)
}
return nil, aitableStatsValidationf("--%s 包含无效的尾随内容: %v", name, err)
}
return value, nil
}
func validateAitableStatsFilters(value map[string]any, rawJSON string) error {
if err := validateFiltersStructure(value, rawJSON); err != nil {
return aitableStatsValidationf("%v", err)
}
return nil
}
func validateAitableArrayDSL(name, raw string) error {
var value []any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return aitableStatsValidationf("--%s 必须是 JSON 数组字符串: %v", name, err)
}
if len(value) == 0 {
return aitableStatsValidationf("--%s 至少需要一个条目", name)
}
for index, item := range value {
if _, ok := item.(map[string]any); !ok {
return aitableStatsValidationf("--%s[%d] 必须是 JSON 对象,got %T", name, index, item)
}
}
return nil
}
// normalizeViewConfigFilter 将 view config 中的 filter 字段规范化为服务端要求的数组格式。
// 服务端 POJO 要求 config.filter 为 []FilterRule(JSON array)。
// 常见错误格式:
@@ -1098,7 +1194,7 @@ func newAitableCommand() *cobra.Command {
dws aitable base [list|search|get|get-primary-doc-id|create|update|delete|copy] Base 管理
dws aitable table [get|create|update|delete] 数据表管理
dws aitable field [get|create|update|delete|search-options] 字段管理
dws aitable record [query|create|update|delete] 记录管理
dws aitable record [query|stats|group-stats|create|update|delete] 记录管理
dws aitable view [get|create|update|delete] 视图管理
dws aitable form [list|delete|update] 表单管理
dws aitable form field [list|update|hide] 表单字段管理
@@ -2245,6 +2341,199 @@ newFieldName、config、aiConfig 至少传入一项。
},
})
recordStatsCmd := &cobra.Command{
Use: "stats",
Short: "整表或过滤后的字段聚合统计",
Long: `对单张数据表执行不分组的服务端聚合,底层调用 query_records_stats。
适用于记录计数、求和、平均值、最大值、最小值、中位数、去重数、完整率等标量统计。
--stats 是 JSON 数组,单次最多 20 项;每项必须包含 fieldId 和大写 statsType。
同一个 fieldId 不能在一次请求中重复,如需对同一字段计算多个指标,请拆成多次调用。
常用 statsType:COUNT、COUNT_COLUMN、SUM、AVG、MAX、MIN、MEDIAN、STANDARD_DEVIATION、RANGE、
DISTINCT、DISTINCT_RATIO、EXISTS、UN_EXISTS、EXIST_RATIO、UN_EXIST_RATIO、CHECKED、UN_CHECKED、
CHECKED_RATIO、UN_CHECKED_RATIO、LATEST_DATE、EARLIEST_DATE、DATE_RANGE、DATE_RANGE_MONTH。
需要统计全部匹配记录时不要传 --limit;limit 会改变参与统计的记录范围。
lt/gt/lte/gte 的过滤值必须使用 JSON 数字;单选/多选过滤建议使用 field get 返回的选项 ID。`,
Example: ` dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldAmount","statsType":"SUM"}]'
dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --filters '{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}' --stats '[{"fieldId":"fldTitle","statsType":"COUNT"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), true, true, 20)
if err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
"stats": stats,
}
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
filters, err := parseAitableObjectFlag("filters", raw)
if err != nil {
return err
}
if err := validateAitableStatsFilters(filters, raw); err != nil {
return err
}
toolArgs["filters"] = normalizeFilters(filters)
}
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
if err := validateAitableArrayDSL("sort", raw); err != nil {
return err
}
// query_records_stats 的 MCP Schema 将 sort 定义为 JSON 数组编码后的字符串,
// 与 query_records 使用的数组类型不同。
toolArgs["sort"] = raw
}
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
if value <= 0 {
return aitableStatsValidationf("--limit 必须大于 0;统计全部匹配记录时请省略该参数")
}
toolArgs["limit"] = value
}
if value, _ := cmd.Flags().GetString("keyword"); value != "" {
toolArgs["keyword"] = value
}
if value, _ := cmd.Flags().GetString("search-field-ids"); value != "" {
toolArgs["searchFieldIds"] = parseCSVValues(value)
}
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
toolArgs["dataVersion"] = value
}
return callAitableTool("query_records_stats", toolArgs)
},
}
DeclareLeafMetadata(recordStatsCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "query_records_stats",
CanonicalPath: "aitable.query_records_stats",
CLIPath: "aitable record stats",
PrimaryCLIPath: "aitable record stats",
},
Description: "对整表或过滤后的记录执行不分组字段聚合。",
Interface: aitableMCPInterface("query_records_stats"),
Selection: contract.SelectionSpec{
AgentSummary: "对整表或过滤后的记录执行不分组字段聚合。",
UseWhen: []string{"需要记录总数、SUM/AVG/MAX/MIN、中位数、去重数、完整率等标量统计时,优先服务端聚合"},
AvoidWhen: []string{"需要按字段分组或使用 query_stats 高级聚合时用 record group-stats;需要行级明细时用 record query"},
Examples: []string{`dws aitable record stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"COUNT"}]'`},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
{Name: "filters", Property: "filters", InterfaceType: "object"},
{Name: "search-field-ids", Property: "searchFieldIds", InterfaceType: "array"},
{Name: "data-version", Property: "dataVersion"},
},
Result: aitableRecordsStatsResultSpec(),
},
})
recordGroupStatsCmd := &cobra.Command{
Use: "group-stats",
Short: "分组、去重及高级聚合统计",
Long: `对单张数据表执行分组或高级服务端聚合,底层调用 query_stats,最多返回 1000 个分组。
适用于按一个或多个字段分组、条件唯一实体计数,以及 distinct、distinct_ratio 等高级统计。
--stats 是 JSON 数组,每项包含 fieldId 和小写 statsType。基础类型为 sum、avg、count、max、min;
部分后端也支持 median、distinct、distinct_ratio 等高级类型,不支持时服务端会返回明确错误。
--group 是 JSON 数组编码后的字符串,不分组的 DISTINCT 标量统计可以省略。
不要依赖服务端 limit 选择 Top N;应在不超过 1000 个分组时获取完整结果后再排序。`,
Example: ` dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --group '[{"fieldId":"fldCategory","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]' --stats '[{"fieldId":"fldAmount","statsType":"sum"}]'
dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldStore","statsType":"distinct"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), false, false, 0)
if err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
"stats": stats,
}
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
filters, err := parseAitableObjectFlag("filters", raw)
if err != nil {
return err
}
if err := validateAitableStatsFilters(filters, raw); err != nil {
return err
}
toolArgs["filters"] = normalizeFilters(filters)
}
if raw, _ := cmd.Flags().GetString("group"); raw != "" {
if err := validateAitableArrayDSL("group", raw); err != nil {
return err
}
toolArgs["group"] = raw
}
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
if err := validateAitableArrayDSL("sort", raw); err != nil {
return err
}
toolArgs["sortDsl"] = raw
}
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
if value < 1 || value > 1000 {
return aitableStatsValidationf("--limit 必须在 [1, 1000] 范围内,got %d", value)
}
toolArgs["limit"] = value
}
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
toolArgs["dataVersion"] = value
}
return callAitableTool("query_stats", toolArgs)
},
}
DeclareLeafMetadata(recordGroupStatsCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "query_stats",
CanonicalPath: "aitable.query_stats",
CLIPath: "aitable record group-stats",
PrimaryCLIPath: "aitable record group-stats",
},
Description: "按字段分组,或执行去重等高级聚合统计。",
Interface: aitableMCPInterface("query_stats"),
Selection: contract.SelectionSpec{
AgentSummary: "按字段分组,或执行去重等高级聚合统计。",
UseWhen: []string{"需要各分类/实体的分组统计,或对满足条件的门店、客户、商品等执行 DISTINCT 唯一计数时"},
AvoidWhen: []string{"普通不分组 COUNT/SUM/AVG/MAX/MIN 优先用 record stats;原始记录和 Top N 明细用 record query"},
Examples: []string{`dws aitable record group-stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"distinct"}]'`},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
{Name: "filters", Property: "filters", InterfaceType: "object"},
{Name: "group", Property: "group"},
{Name: "sort", Property: "sortDsl"},
{Name: "data-version", Property: "dataVersion"},
},
Result: aitableGroupedStatsResultSpec(),
},
})
recordCreateCmd := &cobra.Command{
Use: "create",
Short: "新增记录",
@@ -7230,6 +7519,23 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
_ = recordQueryCmd.Flags().MarkHidden("view-id")
recordStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
recordStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
recordStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"SUM"}];statsType 必须大写,最多 20 项且 fieldId 不得重复`)
recordStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
recordStatsCmd.Flags().String("sort", "", `参与统计记录的排序 DSL(JSON 数组字符串),例如 [{"fieldId":"fldDate","direction":"ASC"}]`)
recordStatsCmd.Flags().Int("limit", 0, "参与统计的最大记录数;省略表示统计全部匹配记录")
recordStatsCmd.Flags().String("keyword", "", "全文关键词,仅匹配的记录参与统计")
recordStatsCmd.Flags().String("search-field-ids", "", "关键词搜索字段 ID 列表,逗号分隔;仅 --keyword 非空时生效")
recordStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
recordGroupStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
recordGroupStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
recordGroupStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"sum"}];statsType 必须小写`)
recordGroupStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
recordGroupStatsCmd.Flags().String("group", "", `分组字段 DSL(JSON 数组字符串);不分组的 distinct 统计可省略`)
recordGroupStatsCmd.Flags().String("sort", "", "统计结果排序 DSL(JSON 数组字符串),映射到 MCP sortDsl")
recordGroupStatsCmd.Flags().Int("limit", 0, "返回的分组结果数,范围 1-1000;省略表示不额外限制")
recordGroupStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
recordCreateCmd.Flags().String("table-id", "", "Table ID,可通过 base get 获取 (必填)")
recordCreateCmd.Flags().String("records", "", "待创建的记录列表 JSON 数组,单次最多 100 条 (必填)")
@@ -7294,7 +7600,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
recordPrimaryDocCreateCmd.Flags().String("record-id", "", "目标 Record ID (必填)")
recordCmd.AddCommand(
recordQueryCmd, recordCreateCmd,
recordQueryCmd, recordStatsCmd, recordGroupStatsCmd, recordCreateCmd,
recordUpdateCmd, recordDeleteCmd,
recordBatchUpdateCmd,
recordHistoryListCmd,
+101
View File
@@ -14,6 +14,8 @@
package helpers
import (
"encoding/json"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
@@ -72,3 +74,102 @@ func aitableCompositeInterface(reason string) *contract.InterfaceSpec {
Reason: reason,
}
}
func aitableRecordsStatsResultSpec() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{
contract.ResultOutcomeSuccess,
contract.ResultOutcomeFailure,
},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"不分组的字段聚合结果",
"properties":{
"results":{
"type":"array",
"description":"按数据版本返回的聚合结果批次",
"items":{
"type":"object",
"properties":{
"dataVersion":{"description":"参与统计的数据版本"},
"deltaVersion":{"description":"聚合结果的增量版本"},
"results":{
"type":"array",
"description":"请求中各统计项的结果",
"items":{
"type":"object",
"properties":{
"fieldId":{"type":"string","description":"被统计的字段 ID"},
"statsType":{"type":"string","description":"实际执行的统计类型"},
"value":{"description":"统计值;具体 JSON 类型取决于统计类型和字段类型"}
},
"required":["fieldId","statsType","value"],
"additionalProperties":true
}
}
},
"required":["results"],
"additionalProperties":true
}
}
},
"required":["results"],
"additionalProperties":true
}`),
}
}
func aitableGroupedStatsResultSpec() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{
contract.ResultOutcomeSuccess,
contract.ResultOutcomeFailure,
},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"分组或高级字段聚合结果",
"properties":{
"dataVersion":{"description":"参与统计的数据版本"},
"results":{
"type":"array",
"description":"每个分组一条结果;未分组时通常只有一条",
"items":{
"type":"object",
"properties":{
"groupKeys":{
"type":"array",
"description":"当前结果的分组键",
"items":{
"type":"object",
"properties":{
"fieldId":{"type":"string","description":"分组字段 ID"},
"value":{"description":"分组值;编码取决于字段类型"},
"recordCount":{"type":"integer","description":"该分组包含的记录数"}
},
"additionalProperties":true
}
},
"fieldStatsMap":{
"type":"object",
"description":"按字段 ID 索引的聚合值",
"additionalProperties":{
"type":"object",
"properties":{
"action":{"type":"string","description":"实际执行的统计动作"},
"value":{"description":"统计值;具体 JSON 类型取决于统计动作和字段类型"}
},
"required":["action","value"],
"additionalProperties":true
}
}
},
"required":["fieldStatsMap"],
"additionalProperties":true
}
}
},
"required":["results"],
"additionalProperties":true
}`),
}
}
+278
View File
@@ -0,0 +1,278 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func runAitableStatsCLI(t *testing.T, args ...string) (*aitableTestCaller, error) {
t.Helper()
caller := &aitableTestCaller{}
installAitableDeps(t, caller)
command := newAitableCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(args)
return caller, command.Execute()
}
func TestCrossPlatformCoverageAitableRecordsStatsForwardsValidatedContract(t *testing.T) {
caller, err := runAitableStatsCLI(t,
"record", "stats",
"--base-id", "base-stats",
"--table-id", "table-stats",
"--stats", `[{"fieldId":"fldCount","statsType":"COUNT"},{"fieldId":"fldAmount","statsType":"SUM"}]`,
"--filters", `{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}`,
"--sort", `[{"fieldId":"fldAmount","direction":"DESC"}]`,
"--limit", "500",
"--keyword", "华东",
"--search-field-ids", "fldName,fldRegion",
"--data-version", "42",
)
if err != nil {
t.Fatalf("record stats returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.server != "aitable" || call.tool != "query_records_stats" {
t.Fatalf("tool = %s/%s, want aitable/query_records_stats", call.server, call.tool)
}
wantStats := []map[string]any{
{"fieldId": "fldCount", "statsType": "COUNT"},
{"fieldId": "fldAmount", "statsType": "SUM"},
}
if !reflect.DeepEqual(call.args["stats"], wantStats) {
t.Fatalf("stats = %#v, want %#v", call.args["stats"], wantStats)
}
filters := call.args["filters"].(map[string]any)
condition := filters["operands"].([]any)[0].(map[string]any)
if got := condition["operands"].([]any)[1]; got != json.Number("0") {
t.Fatalf("numeric filter value = %#v (%T), want json.Number(0)", got, got)
}
if call.args["sort"] != `[{"fieldId":"fldAmount","direction":"DESC"}]` ||
call.args["limit"] != 500 || call.args["keyword"] != "华东" || call.args["dataVersion"] != "42" {
t.Fatalf("forwarded args = %#v", call.args)
}
if !reflect.DeepEqual(call.args["searchFieldIds"], []string{"fldName", "fldRegion"}) {
t.Fatalf("searchFieldIds = %#v", call.args["searchFieldIds"])
}
}
func TestCrossPlatformCoverageAitableGroupStatsForwardsStringDSL(t *testing.T) {
group := `[{"fieldId":"fldRegion","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]`
sortDSL := `[{"fieldId":"fldAmount","direction":"DESC"}]`
caller, err := runAitableStatsCLI(t,
"record", "group-stats",
"--base-id", "base-stats",
"--table-id", "table-stats",
"--stats", `[{"fieldId":"fldStore","statsType":"distinct"},{"fieldId":"fldAmount","statsType":"avg"}]`,
"--filters", `{"operator":"OR","operands":[{"operator":"not_before","operands":["fldDate","2026-01-01"]}]}`,
"--group", group,
"--sort", sortDSL,
"--limit", "1000",
"--data-version", "43",
)
if err != nil {
t.Fatalf("record group-stats returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.server != "aitable" || call.tool != "query_stats" {
t.Fatalf("tool = %s/%s, want aitable/query_stats", call.server, call.tool)
}
if call.args["group"] != group || call.args["sortDsl"] != sortDSL || call.args["limit"] != 1000 || call.args["dataVersion"] != "43" {
t.Fatalf("forwarded args = %#v", call.args)
}
stats := call.args["stats"].([]map[string]any)
if len(stats) != 2 || stats[0]["statsType"] != "distinct" || stats[1]["statsType"] != "avg" {
t.Fatalf("stats = %#v", stats)
}
}
func TestCrossPlatformCoverageAitableStatsRejectsInvalidInputsBeforeCallingMCP(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "records stats require uppercase",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`},
wantErr: "大写",
},
{
name: "records stats require at least one item",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[]`},
wantErr: "至少需要一个统计项",
},
{
name: "records stats enforce item cap",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[
{"fieldId":"f01","statsType":"COUNT"},{"fieldId":"f02","statsType":"COUNT"},
{"fieldId":"f03","statsType":"COUNT"},{"fieldId":"f04","statsType":"COUNT"},
{"fieldId":"f05","statsType":"COUNT"},{"fieldId":"f06","statsType":"COUNT"},
{"fieldId":"f07","statsType":"COUNT"},{"fieldId":"f08","statsType":"COUNT"},
{"fieldId":"f09","statsType":"COUNT"},{"fieldId":"f10","statsType":"COUNT"},
{"fieldId":"f11","statsType":"COUNT"},{"fieldId":"f12","statsType":"COUNT"},
{"fieldId":"f13","statsType":"COUNT"},{"fieldId":"f14","statsType":"COUNT"},
{"fieldId":"f15","statsType":"COUNT"},{"fieldId":"f16","statsType":"COUNT"},
{"fieldId":"f17","statsType":"COUNT"},{"fieldId":"f18","statsType":"COUNT"},
{"fieldId":"f19","statsType":"COUNT"},{"fieldId":"f20","statsType":"COUNT"},
{"fieldId":"f21","statsType":"COUNT"}
]`},
wantErr: "单次最多支持 20 个统计项",
},
{
name: "records stats require item fields",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":" ","statsType":"COUNT"}]`},
wantErr: "均不能为空",
},
{
name: "records stats reject unknown item fields",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT","unknownKey":123}]`},
wantErr: "不支持的字段",
},
{
name: "records stats reject duplicate field",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"},{"fieldId":"f","statsType":"SUM"}]`},
wantErr: "重复",
},
{
name: "group stats require lowercase",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`},
wantErr: "小写",
},
{
name: "group must be array DSL",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--group", `{}`},
wantErr: "JSON 数组",
},
{
name: "group limit capped",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--limit", "1001"},
wantErr: "[1, 1000]",
},
{
name: "filters require logical root",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"eq","operands":[]}`},
wantErr: `root "operator" must be "and" or "or"`,
},
{
name: "records filters reject malformed JSON",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{`},
wantErr: "必须是 JSON 对象",
},
{
name: "records filters reject null",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `null`},
wantErr: "不能是 null",
},
{
name: "records filters reject a second JSON value",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} {}`},
wantErr: "只能包含一个 JSON 对象",
},
{
name: "records filters reject invalid trailing content",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} trailing`},
wantErr: "无效的尾随内容",
},
{
name: "records filters require operand array",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":{}}`},
wantErr: `"operands" must be an array`,
},
{
name: "records filters reject silently unsupported operators",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
wantErr: `unsupported filter operator "date_between"`,
},
{
name: "records sort requires an item",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[]`},
wantErr: "至少需要一个条目",
},
{
name: "records sort items must be objects",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[1]`},
wantErr: "必须是 JSON 对象",
},
{
name: "records limit must be positive",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--limit", "0"},
wantErr: "必须大于 0",
},
{
name: "group filters reject malformed JSON",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{`},
wantErr: "必须是 JSON 对象",
},
{
name: "group filters require logical root",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"eq","operands":[]}`},
wantErr: `root "operator" must be "and" or "or"`,
},
{
name: "group filters reject silently unsupported operators",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
wantErr: `unsupported filter operator "date_between"`,
},
{
name: "group sort rejects invalid DSL",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--sort", `{}`},
wantErr: "JSON 数组",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller, err := runAitableStatsCLI(t, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
}
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation {
t.Fatalf("error = %#v, want structured validation error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %d, want 0", len(caller.calls))
}
})
}
}
func TestCrossPlatformCoverageAitableStatsPublishInterfaceAndResultContracts(t *testing.T) {
root := newAitableCommand()
tests := []struct {
path string
rpc string
}{
{path: "aitable record stats", rpc: "query_records_stats"},
{path: "aitable record group-stats", rpc: "query_stats"},
}
for _, test := range tests {
leaf := findCLIPath(root, test.path)
if leaf == nil {
t.Fatalf("missing leaf %q", test.path)
}
final, ok := contractfinal.RuntimeContractFinal(leaf)
if !ok || final.Interface == nil || final.Interface.Ref == nil || final.Interface.Ref.RPCName != test.rpc {
t.Fatalf("%s interface = %#v", test.path, final.Interface)
}
if final.Result == nil || len(final.Result.Outcomes) == 0 || len(final.Result.DataSchema) == 0 {
t.Fatalf("%s result = %#v", test.path, final.Result)
}
}
}
+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 (必填)")
+160 -67
View File
@@ -19,15 +19,29 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// promoteLegacyChatString copies an explicitly supplied legacy flag into the
// new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the
// overwrite also preserves the migration rule that a legacy value wins when
// both spellings are present.
func promoteLegacyChatString(cmd *cobra.Command, canonical, legacy string) error {
if !cmd.Flags().Changed(legacy) {
return nil
}
value, err := cmd.Flags().GetString(legacy)
if err != nil {
return err
}
return cmd.Flags().Set(canonical, value)
}
func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("chat", toolName, args)
@@ -3144,7 +3158,7 @@ func newChatCommand() *cobra.Command {
--open-dingtalk-id 单聊接收人 openDingTalkId
纯文本 / Markdown 消息(默认):
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
无需指定 --msg-type,直接传消息内容即可。推荐使用 --content flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
图文混排时,公网图片 URL 需要写成 Markdown 图片语法:![图片标题](https://example.com/image.png),才会以内联图片展示。
如果省略开头的 !,例如 [图片标题](https://example.com/image.png),将按链接/URL 展示,不会渲染为图片。
@@ -3154,24 +3168,24 @@ func newChatCommand() *cobra.Command {
获取 openMessageId 和 openConversationId,无需再按消息内容反查。
本地图片 / 文件消息:
统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送;
统一使用 --msg-type file --file <本地路径>。CLI 会完成上传并按 file 消息发送;
.png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。
旧版内联图片消息:
仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。
当前 CLI 不提供本地文件到 mediaId 的上传能力。`,
Example: ` dws chat message send --conversation-id <openconversation_id> "hello"
dws chat message send --user <userId> "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> "请查收"
dws chat message send --conversation-id <openconversation_id> --title "周报提醒" "请大家本周五前提交周报"
Example: ` dws chat message send --conversation-id <openconversation_id> --content "hello"
dws chat message send --user <userId> --content "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --content "请查收"
dws chat message send --conversation-id <openconversation_id> --title "周报提醒" --content "请大家本周五前提交周报"
# 图文混排 Markdown:公网图片 URL 需要写成 ![图片标题](URL) 才会以内联图片展示
dws chat message send --conversation-id <openconversation_id> --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)'
dws chat message send --conversation-id <openconversation_id> --content $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)'
# 发送本地图片或文件(图片会作为可下载的 file 附件发送)
dws chat message send --conversation-id <openconversation_id> --msg-type file --file-path ./screenshot.png
dws chat message send --conversation-id <openconversation_id> --msg-type file --file-path ./report.pdf
dws chat message send --conversation-id <openconversation_id> --msg-type file --file ./screenshot.png
dws chat message send --conversation-id <openconversation_id> --msg-type file --file ./report.pdf
# 发送本地音频/视频(audio/video 是 file 的语义别名)
dws chat message send --conversation-id <openconversation_id> --msg-type audio --file-path ./recording.mp3
dws chat message send --conversation-id <openconversation_id> --msg-type video --file-path ./demo.mp4
dws chat message send --conversation-id <openconversation_id> --msg-type audio --file ./recording.mp3
dws chat message send --conversation-id <openconversation_id> --msg-type video --file ./demo.mp4
# 旧版内联图片:仅当上游已经持有有效 mediaId 时使用
dws chat message send --conversation-id <openconversation_id> --msg-type image --media-id <mediaId>
# 查询群 ID: dws chat search --query "群名"
@@ -3250,7 +3264,7 @@ func newChatCommand() *cobra.Command {
contentJSON = fmt.Sprintf(`{"mediaId":"%s"}`, mediaId)
case "file", "audio", "video":
serviceMsgType = "file"
filePath, _ := cmd.Flags().GetString("file-path")
filePath := flagOrFallback(cmd, "file-path", "file")
dentryId, _ := cmd.Flags().GetInt64("dentry-id")
spaceId, _ := cmd.Flags().GetInt64("space-id")
if (dentryId == 0) != (spaceId == 0) {
@@ -3281,7 +3295,7 @@ func newChatCommand() *cobra.Command {
}
contentJSON, _ = buildConversationFileContent(dentryId, spaceId, meta)
} else if dentryId == 0 || spaceId == 0 {
return fmt.Errorf("--file-path must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err)
return fmt.Errorf("--file must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err)
}
}
if contentJSON == "" {
@@ -3289,7 +3303,7 @@ func newChatCommand() *cobra.Command {
fileType, _ := cmd.Flags().GetString("file-type")
fileSize, _ := cmd.Flags().GetInt64("file-size")
if dentryId == 0 || spaceId == 0 || fileName == "" {
return fmt.Errorf("readable local --file-path is required for msgType=file; legacy flags --dentry-id, --space-id, --file-name are still supported")
return fmt.Errorf("readable local --file is required for msgType=file; legacy flags --dentry-id, --space-id, --file-name are still supported")
}
contentJSON = fmt.Sprintf(`{"dentryId":%d,"spaceId":%d,"fileName":"%s","fileType":"%s","filePath":"%s","fileSize":%d}`,
dentryId, spaceId, fileName, fileType, filePath, fileSize)
@@ -3335,7 +3349,7 @@ func newChatCommand() *cobra.Command {
text = args[0]
}
if text == "" {
return fmt.Errorf("message content required (use --text or positional arg, or --media-id for image)")
return fmt.Errorf("message content required (use --content or positional arg, or --media-id for image)")
}
title, _ := cmd.Flags().GetString("title")
if title == "" {
@@ -3395,7 +3409,7 @@ func newChatCommand() *cobra.Command {
AgentSummary: "以当前用户身份发送群聊或单聊消息",
UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时;响应返回 openTaskId 后用 chat message query-send-status 确认投递并取得后续操作所需的消息 ID"},
AvoidWhen: []string{"机器人身份或 Webhook 发送应使用对应命令"},
Examples: []string{"dws chat message send --group <openConversationId> \"项目已更新\""},
Examples: []string{"dws chat message send --group <openConversationId> --content \"项目已更新\""},
},
Parameters: []contract.ParamDecl{
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
@@ -3413,10 +3427,11 @@ func newChatCommand() *cobra.Command {
chatMessageSendByBotCmd := &cobra.Command{
Use: "send-by-bot",
Short: "机器人发送消息(--conversation-id 群聊 / --users 单聊)",
Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。
Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。机器人引用回复仅支持群聊 Markdown,同时传 --reply(原消息 openMessageId)和 --ref-sender(原消息发送者 openDingTalkId);回复可省略 --title,CLI 会从正文生成标题。
⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。`,
Example: ` dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --reply <openMessageId> --ref-sender <openDingTalkId> --text "收到"
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --msg-type image --image-url "https://example.com/image.png"
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --msg-type file --file-path ./report.pdf
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
@@ -3433,10 +3448,17 @@ func newChatCommand() *cobra.Command {
text := mustGetFlag(cmd, "text")
imageURL := strings.TrimSpace(mustGetFlag(cmd, "image-url"))
filePath := mustGetFlag(cmd, "file-path")
referenceOpenMessageID := strings.TrimSpace(mustGetFlag(cmd, "reply"))
refSender := strings.TrimSpace(mustGetFlag(cmd, "ref-sender"))
replyChanged := cmd.Flags().Changed("reply")
refSenderChanged := cmd.Flags().Changed("ref-sender")
if err := validateRequiredFlags(cmd, "robot-code"); err != nil {
return err
}
if replyChanged != refSenderChanged || (replyChanged && (referenceOpenMessageID == "" || refSender == "")) {
return fmt.Errorf("--reply and --ref-sender must be specified together")
}
if msgType == "" {
if imageURL != "" {
return fmt.Errorf("--msg-type image is required when using --image-url")
@@ -3448,8 +3470,17 @@ func newChatCommand() *cobra.Command {
}
switch msgType {
case "markdown":
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
return err
if referenceOpenMessageID != "" {
if err := validateRequiredFlags(cmd, "text"); err != nil {
return err
}
if title == "" {
title = sanitizeTitleFromText(text)
}
} else {
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
return err
}
}
case "image":
if imageURL == "" {
@@ -3475,6 +3506,22 @@ func newChatCommand() *cobra.Command {
return fmt.Errorf("--conversation-id or --users/--open-dingtalk-ids is required")
}
if referenceOpenMessageID != "" {
if chatID == "" {
return fmt.Errorf("--reply and --ref-sender are only supported with --conversation-id")
}
if msgType != "markdown" {
return fmt.Errorf("--reply and --ref-sender only support Markdown group messages")
}
if !isOpenDingTalkID(refSender) {
resolved, err := resolveOpenDingTalkID(cmd.Context(), refSender)
if err != nil {
return err
}
refSender = resolved
}
}
userIDs := splitCommaList(usersStr)
openDingTalkIDs := splitCommaList(openDingtalkIdsStr)
fileURL := ""
@@ -3540,6 +3587,10 @@ func newChatCommand() *cobra.Command {
toolArgs["msgKey"] = "sampleMarkdownDX"
}
toolArgs["openConversationId"] = chatID
if referenceOpenMessageID != "" {
toolArgs["referenceOpenMessageId"] = referenceOpenMessageID
toolArgs["srcMsgSendOpenDingTalkId"] = refSender
}
if len(atUserIds) > 0 {
toolArgs["atUserIds"] = atUserIds
}
@@ -3586,17 +3637,20 @@ func newChatCommand() *cobra.Command {
CLIPath: "chat message send-by-bot",
PrimaryCLIPath: "chat message send-by-bot",
},
Description: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
Description: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时"},
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊",
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时", "需要机器人在群聊中引用回复一条已有消息时"},
AvoidWhen: []string{"个人身份发送或自定义 Webhook 告警不要使用"},
Examples: []string{"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\""},
Examples: []string{
"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\"",
"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --reply <openMessageId> --ref-sender <openDingTalkId> --text \"收到\"",
},
},
// Keep title/text required_when out of Schema: the runtime switch above
// enforces Markdown inputs, while adding it breaks merge-base compatibility.
@@ -3605,6 +3659,8 @@ func newChatCommand() *cobra.Command {
{Name: "msg-type", RequiredWhen: "image-url or file-path is provided", Enum: []string{"markdown", "image", "file"}},
{Name: "image-url", RequiredWhen: "msg-type is image"},
{Name: "file-path", RequiredWhen: "msg-type is file"},
{Name: "reply", Property: "referenceOpenMessageId", RequiredWhen: "ref-sender is provided", InterfaceType: "string"},
{Name: "ref-sender", Property: "srcMsgSendOpenDingTalkId", RequiredWhen: "reply is provided", InterfaceType: "string"},
},
},
})
@@ -3674,19 +3730,25 @@ func newChatCommand() *cobra.Command {
chatMessageSendByWebhookCmd := &cobra.Command{
Use: "send-by-webhook",
Short: "自定义机器人 Webhook 发送群消息",
Long: `通过自定义机器人 Webhook 发送群消息。@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。
Long: `通过自定义机器人 Webhook 发送群消息。@ 人时需在 --content 中包含 @userId 或 @手机号,否则 @ 不生效。
⚠️ 重要:该接口会真实发送消息到目标群聊,不可用于测试或试探性调用。调用前必须确认消息内容无误。`,
Example: ` dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90%" --at-all
dws chat message send-by-webhook --token <webhook-token> --title "test" --text "hi @118785" --at-users 118785`,
Example: ` dws chat message send-by-webhook --token <webhook-token> --title "告警" --content "CPU 超 90%" --at-all
dws chat message send-by-webhook --token <webhook-token> --title "test" --content "hi @118785" --at-users 118785`,
PreRunE: func(cmd *cobra.Command, args []string) error {
return promoteLegacyChatString(cmd, "content", "text")
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "token", "title", "text"); err != nil {
if err := promoteLegacyChatString(cmd, "content", "text"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "token", "title", "content"); err != nil {
return err
}
toolArgs := map[string]any{
"robotToken": mustGetFlag(cmd, "token"),
"title": mustGetFlag(cmd, "title"),
"text": mustGetFlag(cmd, "text"),
"text": mustGetFlag(cmd, "content"),
}
if v, _ := cmd.Flags().GetBool("at-all"); v {
toolArgs["isAtAll"] = true
@@ -3761,11 +3823,12 @@ func newChatCommand() *cobra.Command {
"使用企业机器人 robot-code 发消息时用 chat message send-by-bot",
"以个人身份发消息时用 chat message send",
},
Examples: []string{"dws chat message send-by-webhook --token <webhook-token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
Examples: []string{"dws chat message send-by-webhook --token <webhook-token> --title \"告警\" --content \"CPU 超 90%\" --at-all"},
},
Parameters: []contract.ParamDecl{
{Name: "at-all", Property: "isAtAll"},
{Name: "at-users", Property: "atUserIds"},
{Name: "content", Property: "text"},
{Name: "token", Property: "robotToken"},
},
},
@@ -4314,7 +4377,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
Example: ` dws chat message recall --conversation-id <openConversationId> --message-id <openMessageId>
# 发送后撤回:send -> query-send-status -> recall
dws chat message send --conversation-id <openConversationId> --text "待撤回的内容"
dws chat message send --conversation-id <openConversationId> --content "待撤回的内容"
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
dws chat message recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -4375,7 +4438,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
Example: ` dws chat message edit --conversation-id <openConversationId> --message-id <openMessageId> --text "更新后的内容"
# 发送后编辑:send -> query-send-status -> edit
dws chat message send --conversation-id <openConversationId> --text "原始内容"
dws chat message send --conversation-id <openConversationId> --content "原始内容"
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
dws chat message edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId> --text "更新后的内容"
@@ -4711,21 +4774,19 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
chatMessageSendCmd.Flags().String("user", "", "单聊接收人 userId(单聊时与 --open-dingtalk-id 二选一)")
chatMessageSendCmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId(单聊时与 --user 二选一)")
chatMessageSendCmd.Flags().String("title", "", "消息标题,显示在消息列表(可选,未指定时使用消息内容)")
// 别名注册: --text/--content/--body/--message/--markdown → 位置参数
chatMessageSendCmd.Flags().String("text", "", "消息内容(推荐方式,也可用位置参数传递。内容含换行/特殊字符时必须使用此 flag)")
chatMessageSendCmd.Flags().String("content", "", "--text 的别名")
chatMessageSendCmd.Flags().String("body", "", "--text 的别名")
chatMessageSendCmd.Flags().String("message", "", "--text 的别名")
chatMessageSendCmd.Flags().String("markdown", "", "--text 的别名")
// --text 不再隐藏,作为首选传参方式展示在 help 中
_ = chatMessageSendCmd.Flags().MarkHidden("content")
_ = chatMessageSendCmd.Flags().MarkHidden("body")
_ = chatMessageSendCmd.Flags().MarkHidden("message")
_ = chatMessageSendCmd.Flags().MarkHidden("markdown")
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
Name: "content",
Usage: "消息内容(推荐方式,也可用位置参数传递。内容含换行/特殊字符时必须使用此 flag)",
Aliases: []string{"text"},
}})
for _, alias := range []string{"body", "message", "markdown"} {
chatMessageSendCmd.Flags().String(alias, "", "--content 的兼容别名")
_ = chatMessageSendCmd.Flags().MarkHidden(alias)
}
chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>")
chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>")
chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file;image 仅接受已有 mediaId)")
chatMessageSendCmd.Flags().String("latitude", "", "位置消息纬度(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("longitude", "", "位置消息经度(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("location-name", "", "位置消息地址名称(msgType=location 时必填)")
@@ -4735,7 +4796,11 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)")
chatMessageSendCmd.Flags().String("file-name", "", "文件名")
chatMessageSendCmd.Flags().String("file-type", "", "文件类型/扩展名")
chatMessageSendCmd.Flags().String("file-path", "", "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)")
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
Name: "file",
Usage: "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)",
Aliases: []string{"file-path"},
}})
chatMessageSendCmd.Flags().Int64("file-size", 0, "文件大小,单位字节")
_ = chatMessageSendCmd.Flags().MarkHidden("dentry-id")
_ = chatMessageSendCmd.Flags().MarkHidden("space-id")
@@ -4756,19 +4821,19 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
cli.AnnotateRuntimePositionals(chatMessageSendCmd, contract.RuntimeSchemaPositional{
Name: "content",
Type: "string",
Description: "消息内容(也可使用 --text;富媒体消息可省略)",
Description: "消息内容(也可使用 --content;富媒体消息可省略)",
Required: false,
Index: 0,
})
cli.AnnotateRuntimeFlagEnum(chatMessageSendCmd, "msg-type", "image", "file", "audio", "video")
cli.AnnotateRuntimeFlagFormat(chatMessageSendCmd, "file-path", "file-path")
cli.AnnotateRuntimeFlagFormat(chatMessageSendCmd, "file", "file-path")
chatMessageSendByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)")
_ = chatMessageSendByBotCmd.MarkFlagRequired("robot-code")
chatMessageSendByBotCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时必填)")
chatMessageSendByBotCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔,最多20个(单聊时必填)")
chatMessageSendByBotCmd.Flags().String("msg-type", "", "消息类型: markdown/image/file(省略时为 markdown;图片使用 image --image-url;本地文件使用 file --file-path)")
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送 Markdown 时必填)")
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送普通 Markdown 时必填;引用回复省略时从正文生成)")
chatMessageSendByBotCmd.Flags().String("text", "", "Markdown 消息内容(发送 Markdown 时必填;稳定换行用空行,转义形式写 \\n\\n,不要只写 \\n)")
chatMessageSendByBotCmd.Flags().String("image-url", "", "公网图片 URL(msgType=image 时必填)")
chatMessageSendByBotCmd.Flags().String("file-path", "", "本地文件路径(msgType=file 时直接上传并按 file 消息发送)")
@@ -4776,9 +4841,13 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
chatMessageSendByBotCmd.Flags().String("open-dingtalk-ids", "", "用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)")
chatMessageSendByBotCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)")
chatMessageSendByBotCmd.Flags().Bool("at-all", false, "@所有人(可选),服务端接收字符串 true/false")
chatMessageSendByBotCmd.Flags().String("reply", "", "被引用消息的 openMessageId(仅群聊 Markdown;必须与 --ref-sender 同时使用)")
chatMessageSendByBotCmd.Flags().String("ref-sender", "", "被引用消息发送者的 openDingTalkId(仅群聊 Markdown;必须与 --reply 同时使用)")
chatMessageSendByBotCmd.MarkFlagsRequiredTogether("reply", "ref-sender")
cli.AnnotateRuntimeConstraints(chatMessageSendByBotCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"group", "users"}},
RequireOneOf: [][]string{{"group", "users"}},
RequireTogether: [][]string{{"reply", "ref-sender"}},
})
cli.AnnotateRuntimeFlagFormat(chatMessageSendByBotCmd, "file-path", "file-path")
@@ -4792,8 +4861,12 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("token")
chatMessageSendByWebhookCmd.Flags().String("title", "", "消息标题 (必填)")
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("title")
chatMessageSendByWebhookCmd.Flags().String("text", "", "消息内容 (必填)")
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("text")
corecmd.RegisterFlags(chatMessageSendByWebhookCmd, []corecmd.FlagSpec{{
Name: "content",
Usage: "消息内容 (必填)",
Aliases: []string{"text"},
}})
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("content")
chatMessageSendByWebhookCmd.Flags().Bool("at-all", false, "@ 所有人")
chatMessageSendByWebhookCmd.Flags().String("at-mobiles", "", "@ 指定手机号,逗号分隔")
chatMessageSendByWebhookCmd.Flags().String("at-users", "", "@ 指定用户,逗号分隔")
@@ -5003,7 +5076,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
Use: "conversation-info",
Short: "获取会话基础信息",
Long: `获取指定会话的基础信息。
发送本地文件消息请优先使用 dws chat message send --msg-type file --file-path <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`,
发送本地文件消息请优先使用 dws chat message send --msg-type file --file <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`,
Example: ` dws chat conversation-info --conversation-id <openConversationId> --format json
dws chat conversation-info --user <userId> --format json
dws chat conversation-info --open-dingtalk-id <openDingTalkId> --format json`,
@@ -5118,11 +5191,11 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
Hidden: true,
Long: `chat file upload 已下线,不再调用 chat/upload_conversation_file_by_url。
发送本地文件消息请改用 chat message send --msg-type file --file-path;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`,
Example: ` dws chat message send --conversation-id <openConversationId> --msg-type file --file-path ./report.pdf --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf --format json`,
发送本地文件消息请改用 chat message send --msg-type file --file;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`,
Example: ` dws chat message send --conversation-id <openConversationId> --msg-type file --file ./report.pdf --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file-path <本地路径>")
return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file <本地路径>")
},
}
chatFileUploadCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时使用)")
@@ -6888,10 +6961,22 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
- 群聊:dws chat search --query "群名"
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
Example: ` dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content "收到,马上处理"
dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil {
return err
}
return promoteLegacyChatString(cmd, "content", "text")
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil {
return err
}
if err := promoteLegacyChatString(cmd, "content", "text"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "group", "ref-msg-id", "ref-sender", "content"); err != nil {
return err
}
refSender := mustGetFlag(cmd, "ref-sender")
@@ -6908,7 +6993,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
clawType = edition.ClawType()
}
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "conversation-id"),
"openConversationId": mustGetFlag(cmd, "group"),
"msgType": "reply",
"clawType": clawType,
}
@@ -6916,7 +7001,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
replyText := applyCurrentUserGroupMentions(
toolArgs,
mustGetFlag(cmd, "text"),
mustGetFlag(cmd, "content"),
atOpenIDs,
atAll,
)
@@ -6958,24 +7043,32 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
AgentSummary: "引用指定消息发送个人回复",
UseWhen: []string{"用户要针对某条已有消息进行引用回复时"},
AvoidWhen: []string{"无需引用上下文的普通消息使用 chat message send"},
Examples: []string{"dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text \"收到\""},
Examples: []string{"dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content \"收到\""},
},
Parameters: []contract.ParamDecl{
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
{Name: "conversation-id", Property: "openConversationId"},
{Name: "group", Property: "openConversationId"},
},
},
})
chatMessageReplyCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
_ = chatMessageReplyCmd.MarkFlagRequired("conversation-id")
corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{
Name: "group",
Usage: "会话 openConversationId (必填,支持单聊/群聊)",
Aliases: []string{"conversation-id"},
}})
_ = chatMessageReplyCmd.MarkFlagRequired("group")
chatMessageReplyCmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
_ = chatMessageReplyCmd.MarkFlagRequired("ref-msg-id")
chatMessageReplyCmd.Flags().String("ref-sender", "", "被引用消息的发送者 openDingTalkId (必填)")
_ = chatMessageReplyCmd.MarkFlagRequired("ref-sender")
chatMessageReplyCmd.Flags().String("text", "", "回复内容 (必填)")
_ = chatMessageReplyCmd.MarkFlagRequired("text")
corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{
Name: "content",
Usage: "回复内容 (必填)",
Aliases: []string{"text"},
}})
_ = chatMessageReplyCmd.MarkFlagRequired("content")
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
@@ -446,6 +446,100 @@ func TestCrossPlatformCoverageChatBotRichMediaCoverage(t *testing.T) {
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleMarkdownDX" {
t.Fatalf("direct markdown call = %s %#v", caller.tool, caller.args)
}
if _, exists := caller.args["referenceOpenMessageId"]; exists {
t.Fatalf("ordinary direct message unexpectedly contains reply fields: %#v", caller.args)
}
})
t.Run("group Markdown reference reply", func(t *testing.T) {
const senderOpenDingTalkID = "DAAAAAAAAAAAiE"
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--reply", "message-id", "--ref-sender", senderOpenDingTalkID,
"--text", "received",
); err != nil {
t.Fatal(err)
}
if caller.tool != "send_robot_group_message" ||
caller.args["referenceOpenMessageId"] != "message-id" ||
caller.args["srcMsgSendOpenDingTalkId"] != senderOpenDingTalkID ||
caller.args["title"] != "received" {
t.Fatalf("group reference reply call = %s %#v", caller.tool, caller.args)
}
})
t.Run("group reference reply resolves sender user ID", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":[{"userId":"sender-user","openDingTalkId":"D-sender"}]}`},
{text: `{}`},
}}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--reply", "message-id", "--ref-sender", "sender-user",
"--title", "reply", "--text", "received",
); err != nil {
t.Fatal(err)
}
if caller.tool != "send_robot_group_message" || caller.args["srcMsgSendOpenDingTalkId"] != "D-sender" {
t.Fatalf("resolved group reference reply = %s %#v", caller.tool, caller.args)
}
})
t.Run("direct RunE enforces paired reply flags", func(t *testing.T) {
err := runChatCoverageDirect(t, []string{"message", "send-by-bot"}, map[string]string{
"robot-code": "robot",
"conversation-id": "group",
"reply": "message-id",
"title": "reply",
"text": "received",
})
if err == nil || !strings.Contains(err.Error(), "must be specified together") {
t.Fatalf("direct RunE paired reply flags error = %v", err)
}
})
for _, tc := range []struct {
name string
args []string
want string
}{
{name: "reply requires sender", args: []string{"--reply", "message-id"}, want: "ref-sender"},
{name: "sender requires reply", args: []string{"--ref-sender", "D-sender"}, want: "reply"},
{name: "paired reply flags reject empty values", args: []string{"--reply=", "--ref-sender="}, want: "specified together"},
} {
t.Run(tc.name, func(t *testing.T) {
args := []string{
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--title", "reply", "--text", "received",
}
err := runChatCoverageCommand(t, &scriptedToolCaller{}, append(args, tc.args...)...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("paired reply flags error = %v, want %q", err, tc.want)
}
})
}
t.Run("reference reply is group only", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
"--reply", "message-id", "--ref-sender", "D-sender",
"--title", "reply", "--text", "received",
)
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
t.Fatalf("direct reference reply error = %v", err)
}
})
t.Run("reference reply rejects rich media", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--reply", "message-id", "--ref-sender", "D-sender",
"--msg-type", "image", "--image-url", "https://example.test/image.png",
)
if err == nil || !strings.Contains(err.Error(), "only support Markdown") {
t.Fatalf("rich-media reference reply error = %v", err)
}
})
t.Run("direct image", func(t *testing.T) {
+5 -5
View File
@@ -18,7 +18,7 @@ import (
"github.com/spf13/cobra"
)
const chatMediaUploadReplacement = "dws chat message send --msg-type file --file-path <本地路径>"
const chatMediaUploadReplacement = "dws chat message send --msg-type file --file <本地路径>"
func newChatMediaGroup() *cobra.Command {
media := &cobra.Command{
@@ -43,11 +43,11 @@ func newChatMediaUploadCommand() *cobra.Command {
Deprecated: "请改用 " + chatMediaUploadReplacement,
Long: `此命令仅为 1.x 命令行兼容保留,不再读取应用凭证或调用旧版媒体上传接口。
发送本地图片或文件时,请使用 chat message send --msg-type file --file-path。
发送本地图片或文件时,请使用 chat message send --msg-type file --file。
该路径会把图片作为可下载的 file 消息发送,不会生成 mediaId,也不会渲染成内联 image 消息。
如果上游已经提供 mediaId,仍可使用 chat message send --msg-type image --media-id。`,
Example: " dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png\n" +
" dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf",
Example: " dws chat message send --conversation-id <openConversationId> --msg-type file --file ./screenshot.png\n" +
" dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(*cobra.Command, []string) error {
@@ -56,7 +56,7 @@ func newChatMediaUploadCommand() *cobra.Command {
}
// Keep the historical flags so existing argv remains parseable while the
// 1.x compatibility command returns an actionable migration error.
cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file-path")
cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file")
cmd.Flags().String("type", "image", "旧版兼容参数;不再执行媒体上传")
return cmd
}
@@ -28,7 +28,7 @@ func TestCrossPlatformCoverageChatMediaUploadIsDeprecatedCompatibilityStub(t *te
if err == nil {
t.Fatal("deprecated media upload returned nil error")
}
for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file-path", "--media-id"} {
for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file", "--media-id"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("media upload migration error missing %q: %v", want, err)
}
+328
View File
@@ -0,0 +1,328 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
const commentServer = "doc-comment"
var commentBatchResultSchema = json.RawMessage(`{
"type":"object",
"description":"按请求顺序返回的评论详情",
"properties":{
"commentList":{
"type":"array",
"description":"与请求 comments 顺序一致的评论详情列表",
"items":{
"type":"object",
"description":"单条评论查询结果;不存在时 found=false",
"properties":{
"topicId":{"type":"string","description":"评论主题 ID"},
"commentKey":{"type":"string","description":"评论唯一标识"},
"found":{"type":"boolean","description":"该 topicId/commentKey 是否存在"},
"content":{"type":["string","null"],"description":"纯文本评论内容"},
"quote":{"type":["string","null"],"description":"划词评论引用内容"},
"creatorId":{"type":["string","null"],"description":"创建者用户 ID"},
"createTime":{"type":["integer","null"],"description":"创建时间,毫秒时间戳"},
"updateTime":{"type":["integer","null"],"description":"更新时间,毫秒时间戳"},
"isSolved":{"type":["boolean","null"],"description":"是否已解决"},
"isEmoji":{"type":["boolean","null"],"description":"是否为表情回复"},
"replyCommentKey":{"type":["string","null"],"description":"被回复评论的标识"}
},
"required":["topicId","commentKey","found"],
"additionalProperties":true
}
}
},
"required":["commentList"],
"additionalProperties":true
}`)
var commentStatusResultSchema = json.RawMessage(`{
"type":"object",
"description":"评论解决状态更新结果",
"properties":{
"commentKey":{"type":"string","description":"评论唯一标识"},
"resolved":{"type":"boolean","description":"更新后的解决状态"},
"message":{"type":"string","description":"操作结果消息"}
},
"required":["commentKey","resolved"],
"additionalProperties":true
}`)
var commentReactionResultSchema = json.RawMessage(`{
"type":"object",
"description":"表情回复创建结果",
"properties":{
"commentKey":{"type":"string","description":"新建表情回复的评论标识"},
"message":{"type":"string","description":"操作结果消息"}
},
"required":["commentKey"],
"additionalProperties":true
}`)
// newCommentBaseCommands returns the base comment commands shared by Doc and Sheet.
// Both resource domains call the same comment MCP tools; only CLI identity and
// guidance differ.
func newCommentBaseCommands(surface string) []*cobra.Command {
batchCmd := newCommentBatchQueryCommand(surface)
resolveCmd := newCommentStatusCommand(surface, true)
restoreCmd := newCommentStatusCommand(surface, false)
reactCmd := newCommentReactionCommand(surface)
commands := []*cobra.Command{batchCmd, resolveCmd, restoreCmd, reactCmd}
for _, cmd := range commands {
addCommentNodeAliases(cmd)
}
return commands
}
func newCommentBatchQueryCommand(surface string) *cobra.Command {
resourceName := commentResourceName(surface)
cmd := &cobra.Command{
Use: "batch-query",
Aliases: []string{"batch-query-comments", "batch"},
Short: "按 topicId + commentKey 批量查询评论详情",
Long: `批量查询同一文档或表格中的评论详情,单次最多 100 条。
--comment-ref 可重复传入,格式为 topicId:commentKey。topicId 和 commentKey
均可从 comment list 的返回结果中获得。结果严格保持输入顺序;不存在的评论
会返回 found=false 的占位项。`,
Example: fmt.Sprintf(" dws %s comment batch-query --node <NODE_ID> --comment-ref global:<COMMENT_KEY> --comment-ref <TOPIC_ID>:<COMMENT_KEY> --format json", surface),
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
rawRefs, _ := cmd.Flags().GetStringSlice("comment-ref")
refs, err := parseCommentRefs(rawRefs)
if err != nil {
return err
}
return callMCPToolOnServer(commentServer, "batch_query_comments", map[string]any{
"nodeId": nodeID,
"comments": refs,
})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: commentIdentity(surface, "batch_query_comments", "batch-query"),
Description: "按 topicId + commentKey 批量查询评论详情,保持输入顺序并显式标记缺失项",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: commentBatchResultSchema,
},
Interface: commentInterface("batch_query_comments"),
Selection: contract.SelectionSpec{
AgentSummary: "已知多组 topicId/commentKey 时,一次获取完整评论详情",
UseWhen: []string{fmt.Sprintf("需要回查%s comment list 结果中的多条评论详情,或批量核对评论是否仍存在时", resourceName)},
AvoidWhen: []string{fmt.Sprintf("尚未取得%s评论的 topicId/commentKey 时先使用 comment list", resourceName)},
Examples: []string{fmt.Sprintf("dws %s comment batch-query --node <NODE_ID> --comment-ref <TOPIC_ID>:<COMMENT_KEY> --format json", surface)},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "comment-ref", Property: "comments", InterfaceType: "array"},
},
},
})
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
cmd.Flags().StringSlice("comment-ref", nil, "评论引用 topicId:commentKey,可重复;最多 100 条 (必填)")
return cmd
}
func newCommentStatusCommand(surface string, resolve bool) *cobra.Command {
resourceName := commentResourceName(surface)
use := "restore"
rpc := "restore_comment"
short := "将已解决评论恢复为未解决"
resolved := false
if resolve {
use = "resolve"
rpc = "resolve_comment"
short = "将评论标记为已解决"
resolved = true
}
cmd := &cobra.Command{
Use: use,
Aliases: []string{use + "-comment"},
Short: short,
Example: fmt.Sprintf(" dws %s comment %s --node <NODE_ID> --comment-key <COMMENT_KEY> --format json", surface, use),
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
return err
}
return callMCPToolOnServer(commentServer, rpc, map[string]any{
"nodeId": nodeID,
"commentKey": mustGetFlag(cmd, "comment-key"),
})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: commentIdentity(surface, rpc, use),
Description: short,
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: commentStatusResultSchema,
},
Interface: commentInterface(rpc),
Selection: contract.SelectionSpec{
AgentSummary: short,
UseWhen: []string{fmt.Sprintf("用户明确要求把%s中的某条评论标记为%s时", resourceName, map[bool]string{true: "已解决", false: "未解决"}[resolved])},
AvoidWhen: []string{fmt.Sprintf("永久删除%s评论使用 comment delete;修改文字使用 comment update", resourceName)},
Examples: []string{fmt.Sprintf("dws %s comment %s --node <NODE_ID> --comment-key <COMMENT_KEY> --format json", surface, use)},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "comment-key", Property: "commentKey"},
},
},
})
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
cmd.Flags().String("comment-key", "", "目标评论的 commentKey (必填)")
return cmd
}
func newCommentReactionCommand(surface string) *cobra.Command {
resourceName := commentResourceName(surface)
cmd := &cobra.Command{
Use: "react-reply",
Short: "创建一条表情回复",
Long: `创建表情回复的便捷命令。底层复用 reply_comment,并固定传入 emoji=true。
与 comment reply --emoji 一致,--reaction 必须填写钉钉表情名称,不要直接传
Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传“鼓掌”。
当前轻量版仅支持创建,不包含删除或聚合能力。`,
Example: fmt.Sprintf(" dws %s comment react-reply --node <NODE_ID> --comment-key <COMMENT_KEY> --reaction \"憨笑\" --format json", surface),
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
return err
}
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
"nodeId": nodeID,
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
"content": mustGetFlag(cmd, "reaction"),
"emoji": true,
})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: commentIdentity(surface, "react_reply", "react-reply"),
Description: "为指定评论创建表情回复(轻量版)",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: commentReactionResultSchema,
},
Interface: commentInterface("reply_comment"),
Selection: contract.SelectionSpec{
AgentSummary: "使用钉钉表情名称为指定评论创建表情回复",
UseWhen: []string{fmt.Sprintf("用户要用表情回应%s中的某条评论时;先将 Unicode Emoji 转为钉钉表情名称,例如 😄 转为憨笑、👏 转为鼓掌", resourceName)},
AvoidWhen: []string{fmt.Sprintf("%s评论的普通文字回复使用 comment reply;当前轻量版不支持删除 reaction", resourceName)},
Examples: []string{fmt.Sprintf("dws %s comment react-reply --node <NODE_ID> --comment-key <COMMENT_KEY> --reaction \"憨笑\" --format json", surface)},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "comment-key", Property: "replyCommentKey"},
{Name: "reaction", Property: "content"},
},
},
})
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
cmd.Flags().String("comment-key", "", "被回应评论的 commentKey (必填)")
cmd.Flags().String("reaction", "", "钉钉表情名称,不是 Unicode Emoji;例如 😄=憨笑、👏=鼓掌 (必填)")
return cmd
}
func parseCommentRefs(rawRefs []string) ([]map[string]any, error) {
if len(rawRefs) == 0 {
return nil, fmt.Errorf("missing required flag(s): --comment-ref")
}
if len(rawRefs) > 100 {
return nil, fmt.Errorf("--comment-ref 最多可传 100 条,当前为 %d 条", len(rawRefs))
}
refs := make([]map[string]any, 0, len(rawRefs))
for index, raw := range rawRefs {
parts := strings.SplitN(strings.TrimSpace(raw), ":", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return nil, fmt.Errorf("invalid --comment-ref[%d] %q: expected topicId:commentKey", index, raw)
}
refs = append(refs, map[string]any{
"topicId": strings.TrimSpace(parts[0]),
"commentKey": strings.TrimSpace(parts[1]),
})
}
return refs, nil
}
func addCommentNodeAliases(cmd *cobra.Command) {
cmd.Flags().String("url", "", "")
cmd.Flags().String("id", "", "")
cmd.Flags().String("node-id", "", "")
cmd.Flags().String("doc-id", "", "")
cmd.Flags().String("file-id", "", "")
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
_ = cmd.Flags().MarkHidden(name)
}
}
func commentIdentity(surface, name, cliLeaf string) contract.ToolIdentitySpec {
return contract.ToolIdentitySpec{
ProductID: surface,
Name: name,
CanonicalPath: surface + "." + name,
CLIPath: surface + " comment " + cliLeaf,
PrimaryCLIPath: surface + " comment " + cliLeaf,
}
}
func commentResourceName(surface string) string {
if surface == "sheet" {
return "表格"
}
return "文档"
}
func commentInterface(rpc string) *contract.InterfaceSpec {
return &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: commentServer, RPCName: rpc},
}
}
+174
View File
@@ -0,0 +1,174 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func executeCommentBaseCommand(t *testing.T, caller *docCommentMutationCaller, surface string, args ...string) error {
t.Helper()
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
InitDeps(caller)
deps.Out.w = io.Discard
os.Args = []string{"dws", surface}
var cmd *cobra.Command
if surface == "sheet" {
cmd = newSheetCommand()
} else {
cmd = newDocCommand()
}
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
return cmd.Execute()
}
func TestCrossPlatformCoverageCommentBaseCommandsRegisteredForDocAndSheet(t *testing.T) {
for _, surface := range []struct {
name string
cmd *cobra.Command
}{
{name: "doc", cmd: newDocCommand()},
{name: "sheet", cmd: newSheetCommand()},
} {
for _, leaf := range []string{"batch-query", "resolve", "restore", "react-reply"} {
cmd, remaining, err := surface.cmd.Find([]string{"comment", leaf})
if err != nil || len(remaining) != 0 {
t.Fatalf("dws %s comment %s not registered: cmd=%v remaining=%v err=%v",
surface.name, leaf, cmd, remaining, err)
}
}
}
}
func TestCrossPlatformCoverageDocCommentBatchQueryMapsStructuredRefs(t *testing.T) {
caller := &docCommentMutationCaller{}
err := executeCommentBaseCommand(t, caller, "doc",
"comment", "batch-query", "--node", "doc-1",
"--comment-ref", "global:comment-1", "--comment-ref", "topic-2:comment-2")
if err != nil {
t.Fatal(err)
}
want := docCommentMutationCall{
productID: commentServer,
toolName: "batch_query_comments",
args: map[string]any{
"nodeId": "doc-1",
"comments": []map[string]any{
{"topicId": "global", "commentKey": "comment-1"},
{"topicId": "topic-2", "commentKey": "comment-2"},
},
},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
}
func TestCrossPlatformCoverageSheetCommentResolveAndRestoreUseSharedRPCs(t *testing.T) {
caller := &docCommentMutationCaller{}
if err := executeCommentBaseCommand(t, caller, "sheet",
"comment", "resolve", "--node", "sheet-1", "--comment-key", "comment-1"); err != nil {
t.Fatal(err)
}
if err := executeCommentBaseCommand(t, caller, "sheet",
"comment", "restore", "--node", "sheet-1", "--comment-key", "comment-1"); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].toolName != "resolve_comment" || caller.calls[1].toolName != "restore_comment" {
t.Fatalf("tools = %q, %q", caller.calls[0].toolName, caller.calls[1].toolName)
}
wantArgs := map[string]any{"nodeId": "sheet-1", "commentKey": "comment-1"}
if !reflect.DeepEqual(caller.calls[0].args, wantArgs) || !reflect.DeepEqual(caller.calls[1].args, wantArgs) {
t.Fatalf("calls = %#v, want args %#v", caller.calls, wantArgs)
}
}
func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
caller := &docCommentMutationCaller{}
err := executeCommentBaseCommand(t, caller, "doc",
"comment", "react-reply", "--file-id", "doc-1",
"--comment-key", "comment-1", "--reaction", "鼓掌")
if err != nil {
t.Fatal(err)
}
want := docCommentMutationCall{
productID: commentServer,
toolName: "reply_comment",
args: map[string]any{
"nodeId": "doc-1",
"replyCommentKey": "comment-1",
"content": "鼓掌",
"emoji": true,
},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
}
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
for _, surface := range []struct {
name string
cmd *cobra.Command
}{
{name: "doc", cmd: newDocCommand()},
{name: "sheet", cmd: newSheetCommand()},
} {
cmd, remaining, err := surface.cmd.Find([]string{"comment", "react-reply"})
if err != nil || len(remaining) != 0 {
t.Fatalf("dws %s comment react-reply lookup: remaining=%v err=%v", surface.name, remaining, err)
}
reaction := cmd.Flags().Lookup("reaction")
if reaction == nil || !strings.Contains(reaction.Usage, "不是 Unicode Emoji") || !strings.Contains(reaction.Usage, "😄=憨笑") {
t.Fatalf("dws %s reaction guidance = %#v", surface.name, reaction)
}
if !strings.Contains(cmd.Long, "不要直接传") || !strings.Contains(cmd.Example, `--reaction "憨笑"`) {
t.Fatalf("dws %s react-reply help missing DingTalk emoji-name guidance", surface.name)
}
}
}
func TestCrossPlatformCoverageParseCommentRefsRejectsInvalidShapeAndLimit(t *testing.T) {
if _, err := parseCommentRefs(nil); err == nil {
t.Fatal("missing refs returned nil error")
}
if _, err := parseCommentRefs([]string{"missing-separator"}); err == nil {
t.Fatal("invalid ref returned nil error")
}
tooMany := make([]string, 101)
for index := range tooMany {
tooMany[index] = "global:comment"
}
if _, err := parseCommentRefs(tooMany); err == nil {
t.Fatal("101 refs returned nil error")
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ func TestCallbackInboundMediaPreservesFutureRichTextAttachment(t *testing.T) {
// TestExtractCallbackText covers the markdown / richText fallback path used
// when SDK data.Text.Content is empty. This is the recovery path for
// `dws chat message send --group ... --text ...` (defaults to msgType=markdown)
// `dws chat message send --conversation-id ... --content ...` (defaults to msgType=markdown)
// which otherwise gets silently dropped by the connector.
func TestExtractCallbackText(t *testing.T) {
cases := []struct {
+1 -1
View File
@@ -1259,7 +1259,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
}
// Structured-text fallback: DingTalk leaves data.Text.Content blank on
// markdown / richText callbacks (the body ships in data.Content). Without
// this, `dws chat message send --group ... --text ...` — which defaults
// this, `dws chat message send --conversation-id ... --content ...` — which defaults
// to msgType=markdown — hits the drop branch below and the bot looks
// dead to the sender.
if text == "" {
+24 -15
View File
@@ -15,10 +15,10 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
@@ -1018,7 +1018,7 @@ func inferMimeType(fileName string) string {
}
// buildBlockElement 从 flags 构建块元素 JSON 对象。
// 优先级: --element (原始 JSON) > --heading > --text
// 优先级: --element (原始 JSON) > --heading > --text (旧名) > --content
func buildBlockElement(cmd *cobra.Command) (any, error) {
if raw, _ := cmd.Flags().GetString("element"); raw != "" {
var obj any
@@ -1037,13 +1037,13 @@ func buildBlockElement(cmd *cobra.Command) (any, error) {
"heading": map[string]any{"text": h, "level": level},
}, nil
}
if t, _ := cmd.Flags().GetString("text"); t != "" {
if t := flagOrFallback(cmd, "text", "content"); t != "" {
return map[string]any{
"blockType": "paragraph",
"paragraph": map[string]any{"text": t},
}, nil
}
return nil, fmt.Errorf("block content required: --text / --heading / --element")
return nil, fmt.Errorf("block content required: --content / --heading / --element")
}
// buildNodeTransferRunE creates a RunE handler for copy/move commands.
@@ -2127,12 +2127,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
Use: "insert",
Short: "插入块元素",
Long: `向文档插入块元素。通过 --element 传入 JSON 格式的块元素。
可用 --text 快速插入段落,--heading + --level 快速插入标题。
可用 --content 快速插入段落,--heading + --level 快速插入标题。
块类型: paragraph, heading, blockquote, callout, columns,
orderedList, unorderedList, table, sheet, attachment, slot`,
Example: ` # 快捷插入段落
dws doc block insert --node DOC_ID --text "这是一段文字"
dws doc block insert --node DOC_ID --content "这是一段文字"
# 快捷插入标题
dws doc block insert --node DOC_ID --heading "二级标题" --level 2
@@ -2155,7 +2155,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
dws doc block insert --node DOC_ID --element '{"blockType":"unorderedList","unorderedList":{"list":{"listId":"list-2"}},"children":[{"text":"第二项"}]}'
# 在指定位置之前插入
dws doc block insert --node DOC_ID --text "插入内容" --ref-block BLOCK_ID --where before`,
dws doc block insert --node DOC_ID --content "插入内容" --ref-block BLOCK_ID --where before`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -2241,14 +2241,14 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
Selection: contract.SelectionSpec{
AgentSummary: "向文档插入块元素",
UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --text/--heading,复杂块用 --element JSON"},
UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --content/--heading,复杂块用 --element JSON"},
AvoidWhen: []string{
"整篇追加 Markdown 优先 doc update --mode append",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update",
},
Examples: []string{
"dws doc block insert --node <DOC_ID> --text \"这是一段文字\" --format json",
"dws doc block insert --node <DOC_ID> --content \"这是一段文字\" --format json",
"dws doc block insert --node <DOC_ID> --heading \"二级标题\" --level 2 --format json",
},
},
@@ -2265,8 +2265,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
Use: "update",
Short: "更新块元素",
Long: `更新文档中指定块的内容或样式。需提供 --block-id 和块元素内容。
可用 --text 快速更新为段落,--heading + --level 快速更新为标题。`,
Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --text "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node <nodeId>
可用 --content 快速更新为段落,--heading + --level 快速更新为标题。`,
Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --content "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node <nodeId>
dws doc block update --node DOC_ID --block-id BLOCK_ID --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "block-id"); err != nil {
@@ -2338,7 +2338,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
},
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text \"新内容\" --format json"},
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content \"新内容\" --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "content-format", Property: "format"},
@@ -2748,7 +2748,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
// block insert
blockInsertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
blockInsertCmd.Flags().String("text", "", "快捷: 段落文本内容")
blockInsertCmd.Flags().String("heading", "", "快捷: 标题文本")
blockInsertCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
blockInsertCmd.Flags().String("element", "", "块元素 JSON (高级)")
@@ -2762,7 +2761,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
// block update
blockUpdateCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
blockUpdateCmd.Flags().String("block-id", "", "目标块 ID (必填)")
blockUpdateCmd.Flags().String("text", "", "快捷: 段落文本内容")
blockUpdateCmd.Flags().String("heading", "", "快捷: 标题文本")
blockUpdateCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
blockUpdateCmd.Flags().String("element", "", "块元素 JSON (高级)")
@@ -3517,6 +3515,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
}
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd)
commentCmd.AddCommand(newCommentBaseCommands("doc")...)
// ── permission (文档协作权限) ────────────────────────────
permissionCmd := &cobra.Command{
@@ -4643,6 +4642,16 @@ CLI 内部自动完成全部流程:
RegisterCrossProductAliases(child)
}
}
// Register the block content Primary after the cross-product pass. Registering
// --content earlier would make the global content/markdown group expand a new
// --markdown alias onto these commands, which is outside this migration.
for _, cmd := range []*cobra.Command{blockInsertCmd, blockUpdateCmd} {
corecmd.RegisterFlags(cmd, []corecmd.FlagSpec{{
Name: "content",
Usage: "快捷: 段落文本内容",
Aliases: []string{"text"},
}})
}
// ── deprecated 标记:文件管理命令已迁移到 drive ──
deprecatedDocToDrive := map[*cobra.Command]string{
+286 -3
View File
@@ -10,7 +10,10 @@ import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
@@ -42,6 +45,283 @@ func oaFormValues(raw string) ([]map[string]string, error) {
return result, nil
}
func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
var items []map[string]any
if err := decoder.Decode(&items); err != nil {
return nil, fmt.Errorf("--file-infos JSON 解析失败: %w", err)
}
if err := rejectTrailingOAAttachmentJSON(decoder); err != nil {
return nil, err
}
if len(items) < 1 || len(items) > 10 {
return nil, fmt.Errorf("--file-infos 必须包含 1 至 10 个文件")
}
infos := make([]map[string]any, 0, len(items))
for index, item := range items {
for name := range item {
if name != "spaceId" && name != "fileId" {
return nil, fmt.Errorf("--file-infos 第 %d 项包含未知字段 %q", index+1, name)
}
}
spaceValue, ok := item["spaceId"]
if !ok {
return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1)
}
spaceID, ok := spaceValue.(json.Number)
if !ok {
return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1)
}
fileValue, ok := item["fileId"]
if !ok {
return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1)
}
fileID, ok := fileValue.(string)
if !ok {
return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串", index+1)
}
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 不能为空", index+1)
}
infos = append(infos, map[string]any{"spaceId": spaceID, "fileId": fileID})
}
return infos, nil
}
func rejectTrailingOAAttachmentJSON(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return fmt.Errorf("--file-infos JSON 包含多余内容")
}
return fmt.Errorf("--file-infos JSON 解析失败: %w", err)
}
return nil
}
func validateOAAttachmentFileInfos(cmd *cobra.Command, _ []string) error {
raw, _ := cmd.Flags().GetString("file-infos")
_, err := parseOAAttachmentFileInfos(raw)
return err
}
func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
fileIDs, _ := cmd.Flags().GetStringSlice("file-ids")
if len(fileIDs) > 20 {
return fmt.Errorf("--file-ids 最多包含 20 个附件 ID")
}
for index, fileID := range fileIDs {
if strings.TrimSpace(fileID) == "" {
return fmt.Errorf("--file-ids 第 %d 项不能为空", index+1)
}
}
return nil
}
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
if err != nil {
return nil, err
}
response, ok := data.(map[string]any)
if !ok {
return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值不是 JSON 对象", tool))
}
result, ok := response["result"]
if !ok {
return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值缺少 result", tool))
}
return output.Success(result), nil
}
func newOAAttachmentCommand() *cobra.Command {
attachmentCmd := &cobra.Command{
Use: "attachment",
Short: "审批附件授权与下载链接",
RunE: groupRunE,
}
downloadURLCmd := NewLeafCommand(LeafSpec{
Use: "download-url",
Short: "获取审批附件下载链接",
Example: " dws oa approval attachment download-url --instance-id <processInstanceId> --file-id <fileId>",
Server: "oa",
Tool: "get_attachment_download_url",
OutputRollout: output.RolloutUnifiedActive,
ResultCall: callOAAttachmentResult,
Flags: []LeafFlag{
{Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true},
{Name: "file-id", Usage: "审批附件文件 ID (必填)", Bind: "fileId", Trim: true, Required: true, MarkRequired: true},
{Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "get_attachment_download_url",
CanonicalPath: "oa.get_attachment_download_url",
CLIPath: "oa approval attachment download-url",
PrimaryCLIPath: "oa approval attachment download-url",
},
Description: "获取审批附件下载授权并生成临时下载链接",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件临时下载信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"downloadUri":{"type":"string","description":"带临时授权签名的附件下载链接"},"class":{"type":"string","description":"服务端响应类型标识"},"fileId":{"type":"string","description":"审批附件文件 ID"}},"required":["spaceId","agentId","downloadUri","fileId"],"additionalProperties":true}`),
SensitivePaths: []string{"downloadUri"},
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_attachment_download_url"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取审批实例中指定附件的临时下载链接",
UseWhen: []string{"已从审批详情获得 processInstanceId 和 fileId,需要获取附件下载链接时"},
AvoidWhen: []string{
"只需查看审批表单和附件元数据时使用 dws oa approval detail",
"需要将附件真正保存到本地时不要误认为本命令会下载文件;它只返回链接",
},
Examples: []string{"dws oa approval attachment download-url --instance-id <processInstanceId> --file-id <fileId>"},
},
Parameters: []contract.ParamDecl{
{Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"},
{Name: "file-id", Property: "fileId", InterfaceType: "string"},
{Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"},
},
},
})
authorizeDownloadCmd := NewLeafCommand(LeafSpec{
Use: "authorize-download",
Short: "授权当前用户下载审批钉盘文件",
Long: "批量授权当前用户下载指定的审批钉盘文件。",
Example: ` dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`,
Server: "oa",
Tool: "auth_download_file",
OutputRollout: output.RolloutUnifiedActive,
ResultCall: callOAAttachmentResult,
Flags: []LeafFlag{
{
Name: "file-infos", Usage: "审批钉盘文件信息 JSON 数组 (必填)",
Bind: "fileInfos", Trim: true, Required: true, MarkRequired: true,
Transform: func(raw string) (any, error) {
return parseOAAttachmentFileInfos(raw)
},
},
},
Constraints: []LeafConstraint{{
Kind: corecmd.Custom, Flags: []string{"file-infos"},
Description: "文件信息列表必须包含 1 至 10 项",
}},
Validate: validateOAAttachmentFileInfos,
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "auth_download_file",
CanonicalPath: "oa.auth_download_file",
CLIPath: "oa approval attachment authorize-download",
PrimaryCLIPath: "oa approval attachment authorize-download",
},
Description: "批量授权当前用户下载指定的审批钉盘文件",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"boolean","description":"是否成功为当前用户授予审批钉盘文件下载权限"}`),
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_download_file"},
},
Selection: contract.SelectionSpec{
AgentSummary: "使用审批钉盘 spaceId/fileId 批量授权当前用户下载文件",
UseWhen: []string{"已有一个或多个审批钉盘文件的 spaceId 和 fileId,需要为当前用户取得下载权限时"},
AvoidWhen: []string{
"需要生成单个审批附件下载链接时使用 attachment download-url",
"需要授权在审批单内预览附件时使用 attachment authorize-preview",
},
Examples: []string{`dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`},
},
Parameters: []contract.ParamDecl{
{Name: "file-infos", Property: "fileInfos", InterfaceType: "array"},
},
},
})
authorizePreviewCmd := NewLeafCommand(LeafSpec{
Use: "authorize-preview",
Short: "授权当前用户预览审批附件",
Long: "批量授权当前用户预览审批单中的附件。",
Example: " dws oa approval attachment authorize-preview --instance-id <processInstanceId> --file-ids <fileId1>,<fileId2>",
Server: "oa",
Tool: "auth_preview_attachment",
OutputRollout: output.RolloutUnifiedActive,
ResultCall: callOAAttachmentResult,
Flags: []LeafFlag{
{Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true},
{Name: "file-ids", Usage: "附件 ID 列表,多个用逗号分隔 (必填)", Kind: LeafStringSlice, Bind: "fileIdList", Required: true, MarkRequired: true},
{Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"},
},
Constraints: []LeafConstraint{{
Kind: corecmd.Custom, Flags: []string{"file-ids"},
Description: "附件 ID 列表最多包含 20 项且每项不能为空",
}},
Validate: validateOAPreviewFileIDs,
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "auth_preview_attachment",
CanonicalPath: "oa.auth_preview_attachment",
CLIPath: "oa approval attachment authorize-preview",
PrimaryCLIPath: "oa approval attachment authorize-preview",
},
Description: "批量授权当前用户预览审批单中的附件",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件预览授权信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["spaceId","agentId"],"additionalProperties":true}`),
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_preview_attachment"},
},
Selection: contract.SelectionSpec{
AgentSummary: "按审批实例和附件 ID 列表授权当前用户预览附件",
UseWhen: []string{"已有 processInstanceId 和附件 fileId 列表,需要在审批场景中批量取得预览权限时"},
AvoidWhen: []string{
"需要下载权限而不是预览权限时使用 attachment authorize-download",
"需要直接获得单个附件临时下载链接时使用 attachment download-url",
},
Examples: []string{"dws oa approval attachment authorize-preview --instance-id <processInstanceId> --file-ids <fileId1>,<fileId2>"},
},
Parameters: []contract.ParamDecl{
{Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"},
{Name: "file-ids", Property: "fileIdList", InterfaceType: "array"},
{Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"},
},
},
})
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd)
return attachmentCmd
}
// ──────────────────────────────────────────────────────────
// dws oa — OA 审批
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
@@ -49,7 +329,8 @@ func oaFormValues(raw string) ([]map[string]string, error) {
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
// get_inst_revert_activities, get_process_schema, forecast_process,
// start_process_instance
// start_process_instance, get_attachment_download_url, auth_download_file,
// auth_preview_attachment
// ──────────────────────────────────────────────────────────
func newOaCommand() *cobra.Command {
@@ -58,9 +339,10 @@ func newOaCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "oa",
Selection: contract.ProductSelectionDecl{
AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送与评论",
AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送、评论与附件授权",
UseWhen: []string{
"查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时",
"获取审批附件下载链接,或为当前用户授权下载、预览审批附件时",
},
AvoidWhen: []string{
"不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume",
@@ -70,7 +352,7 @@ func newOaCommand() *cobra.Command {
root := &cobra.Command{
Use: "oa",
Short: "OA 审批 / 同意 / 拒绝 / 撤销",
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表。`,
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`,
RunE: groupRunE,
}
@@ -1376,6 +1658,7 @@ func newOaCommand() *cobra.Command {
approvalForecastCmd,
approvalCreateCmd,
)
approvalCmd.AddCommand(newOAAttachmentCommand())
root.AddCommand(approvalCmd)
return root
+339
View File
@@ -0,0 +1,339 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToolCaller, args ...string) (string, error) {
t.Helper()
testseam.Protect(t, &deps)
testseam.Swap(t, &os.Args, []string{"dws", "oa"})
InitDeps(caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = io.Discard
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
cmd.PersistentFlags().String("format", caller.Format(), "输出格式")
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(&stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
return stdout.String(), err
}
func TestCrossPlatformCoverageOAAttachmentDownloadURLPreservesSignedQuery(t *testing.T) {
const response = `{"result":{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"},"success":true}`
commandArgs := []string{
"approval", "attachment", "download-url",
"--instance-id", "instance-1",
"--file-id", "file-1",
}
wantArgs := map[string]any{
"processInstanceId": "instance-1",
"fileId": "file-1",
}
t.Run("json", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.server != "oa" || caller.tool != "get_attachment_download_url" {
t.Fatalf("called %s/%s, want oa/get_attachment_download_url", caller.server, caller.tool)
}
if !reflect.DeepEqual(caller.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", caller.args, wantArgs)
}
if !strings.Contains(stdout, "&OSSAccessKeyId=") || !strings.Contains(stdout, "&Signature=") {
t.Fatalf("stdout does not preserve signed query separators: %q", stdout)
}
if strings.Contains(stdout, `\u0026`) {
t.Fatalf("stdout contains escaped query separator: %q", stdout)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok || !strings.Contains(data["downloadUri"].(string), "&Signature=") {
t.Fatalf("unified data = %#v", envelope["data"])
}
})
t.Run("raw compatibility", func(t *testing.T) {
caller := &scriptedToolCaller{format: "raw", steps: []scriptedToolStep{{text: response}}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
if err != nil {
t.Fatalf("execute command: %v", err)
}
const want = `{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"}` + "\n"
if stdout != want {
t.Fatalf("stdout = %q, want %q", stdout, want)
}
})
}
func TestCrossPlatformCoverageOAAttachmentProjectsUnifiedBusinessData(t *testing.T) {
tests := []struct {
name string
args []string
response string
wantData any
}{
{
name: "download authorization",
args: []string{"approval", "attachment", "authorize-download", "--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`},
response: `{"result":true,"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`,
wantData: true,
},
{
name: "preview authorization",
args: []string{"approval", "attachment", "authorize-preview", "--instance-id", "instance-1", "--file-ids", "file-1"},
response: `{"result":{"spaceId":27827223951,"agentId":4115627346,"class":"com.dingtalk.bpms.oapi.vo.AppSpaceResponse"},"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`,
wantData: map[string]any{
"spaceId": float64(27827223951), "agentId": float64(4115627346),
"class": "com.dingtalk.bpms.oapi.vo.AppSpaceResponse",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: test.response}}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...)
if err != nil {
t.Fatalf("execute command: %v", err)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" || !reflect.DeepEqual(envelope["data"], test.wantData) {
t.Fatalf("unified envelope = %#v, want data %#v", envelope, test.wantData)
}
})
}
}
func TestCrossPlatformCoverageOAAttachmentRejectsSuccessWithoutResult(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`}}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "authorize-download",
"--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`,
)
if err == nil || !strings.Contains(err.Error(), "缺少 result") {
t.Fatalf("execute error = %v, want missing result", err)
}
}
func TestCrossPlatformCoverageOAAttachmentRejectsInvalidToolResult(t *testing.T) {
tests := []struct {
name string
step scriptedToolStep
want string
}{
{name: "non-object response", step: scriptedToolStep{text: `[]`}, want: "不是 JSON 对象"},
{name: "tool error", step: scriptedToolStep{err: errors.New("tool unavailable")}, want: "tool unavailable"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{test.step}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "authorize-download",
"--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`,
)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("execute error = %v, want %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageOAAttachmentPayloads(t *testing.T) {
tests := []struct {
name string
args []string
wantTool string
check func(*testing.T, map[string]any)
}{
{
name: "download URL",
args: []string{
"approval", "attachment", "download-url",
"--instance-id", "instance-1",
"--file-id", "file-1",
"--with-comment-attachment=false",
},
wantTool: "get_attachment_download_url",
check: func(t *testing.T, got map[string]any) {
t.Helper()
if got["processInstanceId"] != "instance-1" || got["fileId"] != "file-1" {
t.Fatalf("download identifiers = %#v", got)
}
if value, ok := got["withCommentAttachment"].(bool); !ok || value {
t.Fatalf("withCommentAttachment = %#v, want explicit false", got["withCommentAttachment"])
}
},
},
{
name: "download authorization",
args: []string{
"approval", "attachment", "authorize-download",
"--file-infos", `[{"spaceId":27827223951,"fileId":"file-2"}]`,
},
wantTool: "auth_download_file",
check: func(t *testing.T, got map[string]any) {
t.Helper()
infos, ok := got["fileInfos"].([]map[string]any)
if !ok || len(infos) != 1 {
t.Fatalf("fileInfos = %#v", got["fileInfos"])
}
if infos[0]["spaceId"] != json.Number("27827223951") || infos[0]["fileId"] != "file-2" {
t.Fatalf("fileInfos[0] = %#v", infos[0])
}
},
},
{
name: "preview authorization",
args: []string{
"approval", "attachment", "authorize-preview",
"--instance-id", "instance-2",
"--file-ids", "file-3,file-4",
"--with-comment-attachment",
},
wantTool: "auth_preview_attachment",
check: func(t *testing.T, got map[string]any) {
t.Helper()
if got["processInstanceId"] != "instance-2" || got["withCommentAttachment"] != true {
t.Fatalf("preview scalar payload = %#v", got)
}
ids, ok := got["fileIdList"].([]string)
if !ok || len(ids) != 2 || ids[0] != "file-3" || ids[1] != "file-4" {
t.Fatalf("fileIdList = %#v", got["fileIdList"])
}
},
},
{
name: "optional boolean omitted",
args: []string{
"approval", "attachment", "download-url",
"--instance-id", "instance-3",
"--file-id", "file-5",
},
wantTool: "get_attachment_download_url",
check: func(t *testing.T, got map[string]any) {
t.Helper()
if _, exists := got["withCommentAttachment"]; exists {
t.Fatalf("optional withCommentAttachment should be omitted: %#v", got)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}}
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...); err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.server != "oa" || caller.tool != test.wantTool {
t.Fatalf("called %s/%s, want oa/%s", caller.server, caller.tool, test.wantTool)
}
test.check(t, caller.args)
})
}
}
func TestCrossPlatformCoverageOAAttachmentRejectsInvalidFileInfos(t *testing.T) {
valid := `{"spaceId":27827223951,"fileId":"file-1"}`
tests := []struct {
name string
raw string
}{
{name: "malformed JSON", raw: `[{`},
{name: "null", raw: `null`},
{name: "not array", raw: `{}`},
{name: "empty array", raw: `[]`},
{name: "more than ten", raw: `[` + strings.Repeat(valid+`,`, 10) + valid + `]`},
{name: "missing space ID", raw: `[{"fileId":"file-1"}]`},
{name: "string space ID", raw: `[{"spaceId":"27827223951","fileId":"file-1"}]`},
{name: "missing file ID", raw: `[{"spaceId":27827223951}]`},
{name: "numeric file ID", raw: `[{"spaceId":27827223951,"fileId":232271651278}]`},
{name: "blank file ID", raw: `[{"spaceId":27827223951,"fileId":" "}]`},
{name: "unknown property", raw: `[{"spaceId":27827223951,"fileId":"file-1","extra":true}]`},
{name: "trailing JSON", raw: `[` + valid + `] {}`},
{name: "malformed trailing JSON", raw: `[` + valid + `] {`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "attachment", "authorize-download",
"--file-infos", test.raw,
)
if err == nil {
t.Fatalf("fileInfos %q unexpectedly succeeded", test.raw)
}
if caller.calls != 0 {
t.Fatalf("invalid fileInfos made %d MCP call(s)", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testing.T) {
tests := []struct {
name string
raw string
}{
{name: "separator only", raw: `,`},
{name: "blank item", raw: `file-1, ,file-2`},
{name: "more than twenty", raw: strings.Repeat("file,", 20) + "file"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "attachment", "authorize-preview",
"--instance-id", "instance-1",
"--file-ids", test.raw,
)
if err == nil {
t.Fatalf("file IDs %q unexpectedly succeeded", test.raw)
}
if caller.calls != 0 {
t.Fatalf("invalid file IDs made %d MCP call(s)", caller.calls)
}
})
}
}
@@ -0,0 +1,499 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type nativePrimaryParamCall struct {
server string
tool string
args map[string]any
}
type nativePrimaryParamCaller struct {
calls []nativePrimaryParamCall
}
func (c *nativePrimaryParamCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for key, value := range args {
copied[key] = value
}
c.calls = append(c.calls, nativePrimaryParamCall{server: server, tool: tool, args: copied})
switch tool {
case "init_conversation_file_upload", "init_todo_file_upload":
return textToolResult(`{"resourceUrl":"https://upload.invalid/file","uploadKey":"upload-key"}`), nil
case "commit_conversation_file_upload":
fileName, _ := args["fileName"].(string)
payload, _ := json.Marshal(map[string]any{
"dentryId": 123,
"spaceId": 456,
"downloadUrl": "https://download.invalid/" + fileName,
})
return textToolResult(string(payload)), nil
case "commit_todo_file_upload":
return textToolResult(`{"dentryId":123,"spaceId":456}`), nil
default:
return textToolResult(`{}`), nil
}
}
func (*nativePrimaryParamCaller) Format() string { return "json" }
func (*nativePrimaryParamCaller) DryRun() bool { return false }
func (*nativePrimaryParamCaller) Fields() string { return "" }
func (*nativePrimaryParamCaller) JQ() string { return "" }
func (c *nativePrimaryParamCaller) lastCall(t *testing.T, tool string) nativePrimaryParamCall {
t.Helper()
for i := len(c.calls) - 1; i >= 0; i-- {
if c.calls[i].tool == tool {
return c.calls[i]
}
}
t.Fatalf("tool %q not called; calls = %#v", tool, c.calls)
return nativePrimaryParamCall{}
}
func executeNativePrimaryTodo(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "todo"}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
cmd := newTodoCommand()
if cmd.PersistentFlags().Lookup("yes") == nil {
cmd.PersistentFlags().Bool("yes", false, "confirm execution")
}
if cmd.PersistentFlags().Lookup("dry-run") == nil {
cmd.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd.ExecuteContext(context.Background())
}
func findNativePrimaryLeaf(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
t.Helper()
leaf, remaining, err := root.Find(path)
if err != nil {
t.Fatalf("find %v: %v", path, err)
}
if len(remaining) != 0 {
t.Fatalf("find %v left arguments %v", path, remaining)
}
return leaf
}
func assertNativePrimaryFlagPair(t *testing.T, cmd *cobra.Command, primary, legacy string) {
t.Helper()
primaryFlag := cmd.Flags().Lookup(primary)
if primaryFlag == nil || primaryFlag.Hidden {
t.Fatalf("%s --%s = %#v, want visible Primary", cmd.CommandPath(), primary, primaryFlag)
}
legacyFlag := cmd.Flags().Lookup(legacy)
if legacyFlag == nil || !legacyFlag.Hidden {
t.Fatalf("%s --%s = %#v, want hidden compatibility alias", cmd.CommandPath(), legacy, legacyFlag)
}
if got := legacyFlag.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != primary {
t.Fatalf("%s --%s alias_of = %#v, want %q", cmd.CommandPath(), legacy, got, primary)
}
if got := legacyFlag.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("%s --%s alias_origin = %#v, want %q", cmd.CommandPath(), legacy, got, runtimeannotate.FlagAliasOriginCorecmdV1)
}
}
func TestNativePrimaryParamFlagSurfaces(t *testing.T) {
aisearch := newAisearchCommand()
assertNativePrimaryFlagPair(t, aisearch, "query", "keyword")
person := findNativePrimaryLeaf(t, aisearch, "person")
assertNativePrimaryFlagPair(t, person, "query", "keyword")
for _, cmd := range []*cobra.Command{aisearch, person} {
if got := cmd.Flags().Lookup("keyword").Shorthand; got != "w" {
t.Fatalf("%s --keyword shorthand = %q, want preserved -w", cmd.CommandPath(), got)
}
if got := cmd.Flags().Lookup("query").Shorthand; got != "" {
t.Fatalf("%s --query shorthand = %q, want none", cmd.CommandPath(), got)
}
}
chat := newChatCommand()
send := findNativePrimaryLeaf(t, chat, "message", "send")
assertNativePrimaryFlagPair(t, send, "content", "text")
assertNativePrimaryFlagPair(t, send, "file", "file-path")
sendByWebhook := findNativePrimaryLeaf(t, chat, "message", "send-by-webhook")
assertNativePrimaryFlagPair(t, sendByWebhook, "content", "text")
sendByBot := findNativePrimaryLeaf(t, chat, "message", "send-by-bot")
for _, name := range []string{"text", "file-path"} {
flag := sendByBot.Flags().Lookup(name)
if flag == nil || flag.Hidden {
t.Fatalf("%s --%s = %#v, want unchanged visible Primary", sendByBot.CommandPath(), name, flag)
}
}
for _, name := range []string{"content", "file"} {
if flag := sendByBot.Flags().Lookup(name); flag != nil && !flag.Hidden {
t.Fatalf("%s --%s = %#v, pending migration must not become visible", sendByBot.CommandPath(), name, flag)
}
}
reply := findNativePrimaryLeaf(t, chat, "message", "reply")
assertNativePrimaryFlagPair(t, reply, "group", "conversation-id")
assertNativePrimaryFlagPair(t, reply, "content", "text")
doc := newDocCommand()
assertNativePrimaryFlagPair(t, findNativePrimaryLeaf(t, doc, "block", "insert"), "content", "text")
assertNativePrimaryFlagPair(t, findNativePrimaryLeaf(t, doc, "block", "update"), "content", "text")
todo := newTodoCommand()
assertNativePrimaryFlagPair(t, findNativePrimaryLeaf(t, todo, "task", "add-attachment"), "file", "file-path")
}
type nativePrimaryParamSpelling struct {
name string
args []string
want string
}
func nativePrimaryParamSpellings(primary, legacy string) []nativePrimaryParamSpelling {
return []nativePrimaryParamSpelling{
{name: "legacy only", args: []string{"--" + legacy, "legacy-value"}, want: "legacy-value"},
{name: "primary only", args: []string{"--" + primary, "primary-value"}, want: "primary-value"},
{name: "both legacy wins", args: []string{"--" + primary, "primary-value", "--" + legacy, "legacy-value"}, want: "legacy-value"},
}
}
func nativePrimaryJSONField(t *testing.T, raw any, field string) string {
t.Helper()
encoded, ok := raw.(string)
if !ok {
t.Fatalf("payload = %#v, want JSON string", raw)
}
var payload map[string]any
if err := json.Unmarshal([]byte(encoded), &payload); err != nil {
t.Fatalf("decode payload %q: %v", encoded, err)
}
value, _ := payload[field].(string)
return value
}
func nativePrimaryParagraphText(t *testing.T, call nativePrimaryParamCall) string {
t.Helper()
element, ok := call.args["element"].(map[string]any)
if !ok {
t.Fatalf("%s element = %#v", call.tool, call.args["element"])
}
paragraph, ok := element["paragraph"].(map[string]any)
if !ok {
t.Fatalf("%s paragraph = %#v", call.tool, element["paragraph"])
}
text, _ := paragraph["text"].(string)
return text
}
func TestNativePrimaryParamTextPayloadCompatibility(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
t.Run("chat message send text to content", func(t *testing.T) {
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
t.Run(spelling.name, func(t *testing.T) {
caller := &nativePrimaryParamCaller{}
args := []string{"message", "send", "--group", "cid", "--title", "fixed-title"}
args = append(args, spelling.args...)
if err := runChatCoverageCommand(t, caller, args...); err != nil {
t.Fatal(err)
}
call := caller.lastCall(t, "send_personal_message")
if got := nativePrimaryJSONField(t, call.args["content"], "text"); got != spelling.want {
t.Fatalf("content.text = %q, want %q; payload = %#v", got, spelling.want, call.args)
}
})
}
})
t.Run("chat message send-by-webhook text to content", func(t *testing.T) {
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
t.Run(spelling.name, func(t *testing.T) {
caller := &nativePrimaryParamCaller{}
args := []string{"message", "send-by-webhook", "--token", "token", "--title", "fixed-title"}
args = append(args, spelling.args...)
if err := runChatCoverageCommand(t, caller, args...); err != nil {
t.Fatal(err)
}
call := caller.lastCall(t, "send_message_by_custom_robot")
if got, _ := call.args["text"].(string); got != spelling.want {
t.Fatalf("text = %q, want %q; payload = %#v", got, spelling.want, call.args)
}
})
}
})
t.Run("chat message reply conversation-id to group", func(t *testing.T) {
for _, spelling := range nativePrimaryParamSpellings("group", "conversation-id") {
t.Run(spelling.name, func(t *testing.T) {
caller := &nativePrimaryParamCaller{}
args := []string{"message", "reply", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--content", "fixed-content"}
args = append(args, spelling.args...)
if err := runChatCoverageCommand(t, caller, args...); err != nil {
t.Fatal(err)
}
call := caller.lastCall(t, "send_personal_message")
if got, _ := call.args["openConversationId"].(string); got != spelling.want {
t.Fatalf("openConversationId = %q, want %q; payload = %#v", got, spelling.want, call.args)
}
})
}
})
t.Run("chat message reply text to content", func(t *testing.T) {
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
t.Run(spelling.name, func(t *testing.T) {
caller := &nativePrimaryParamCaller{}
args := []string{"message", "reply", "--group", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID}
args = append(args, spelling.args...)
if err := runChatCoverageCommand(t, caller, args...); err != nil {
t.Fatal(err)
}
call := caller.lastCall(t, "send_personal_message")
if got := nativePrimaryJSONField(t, call.args["content"], "content"); got != spelling.want {
t.Fatalf("reply content = %q, want %q; payload = %#v", got, spelling.want, call.args)
}
})
}
})
for _, command := range []struct {
name string
path []string
tool string
}{
{name: "doc block insert text to content", path: []string{"block", "insert", "--node", "node"}, tool: "insert_document_block"},
{name: "doc block update text to content", path: []string{"block", "update", "--node", "node", "--block-id", "block"}, tool: "update_document_block"},
} {
command := command
t.Run(command.name, func(t *testing.T) {
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
t.Run(spelling.name, func(t *testing.T) {
caller := &nativePrimaryParamCaller{}
args := append(append([]string(nil), command.path...), spelling.args...)
if err := runDocCoverageCommand(t, caller, args...); err != nil {
t.Fatal(err)
}
call := caller.lastCall(t, command.tool)
if got := nativePrimaryParagraphText(t, call); got != spelling.want {
t.Fatalf("paragraph text = %q, want %q; payload = %#v", got, spelling.want, call.args)
}
})
}
})
}
}
func TestNativePrimaryParamFilePayloadCompatibility(t *testing.T) {
previousDeps, previousPut, previousArgs := deps, httpPutFile, os.Args
t.Cleanup(func() {
deps = previousDeps
httpPutFile = previousPut
os.Args = previousArgs
})
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
dir := t.TempDir()
legacyPath := filepath.Join(dir, "legacy-file.txt")
primaryPath := filepath.Join(dir, "primary-file.txt")
if err := os.WriteFile(legacyPath, []byte("legacy"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(primaryPath, []byte("primary"), 0o600); err != nil {
t.Fatal(err)
}
spellings := []nativePrimaryParamSpelling{
{name: "legacy only", args: []string{"--file-path", legacyPath}, want: filepath.Base(legacyPath)},
{name: "primary only", args: []string{"--file", primaryPath}, want: filepath.Base(primaryPath)},
{name: "both legacy wins", args: []string{"--file", primaryPath, "--file-path", legacyPath}, want: filepath.Base(legacyPath)},
}
t.Run("chat message send file-path to file", func(t *testing.T) {
os.Args = []string{"dws", "chat"}
for _, spelling := range spellings {
t.Run(spelling.name, func(t *testing.T) {
caller := &nativePrimaryParamCaller{}
args := []string{"message", "send", "--group", "cid", "--msg-type", "file"}
args = append(args, spelling.args...)
if err := runChatCoverageCommand(t, caller, args...); err != nil {
t.Fatal(err)
}
call := caller.lastCall(t, "send_personal_message")
if got := nativePrimaryJSONField(t, call.args["content"], "fileName"); got != spelling.want {
t.Fatalf("fileName = %q, want %q; payload = %#v", got, spelling.want, call.args)
}
})
}
})
t.Run("todo task add-attachment file-path to file", func(t *testing.T) {
os.Args = []string{"dws", "todo"}
for _, spelling := range spellings {
t.Run(spelling.name, func(t *testing.T) {
caller := &nativePrimaryParamCaller{}
args := []string{"task", "add-attachment", "--task-id", "42"}
args = append(args, spelling.args...)
if err := executeNativePrimaryTodo(t, caller, args...); err != nil {
t.Fatal(err)
}
call := caller.lastCall(t, "add_todo_attachment")
request, ok := call.args["todoAttachmentAddRequest"].(map[string]any)
if !ok {
t.Fatalf("request = %#v", call.args)
}
attachments, ok := request["attachmentList"].([]any)
if !ok || len(attachments) != 1 {
t.Fatalf("attachments = %#v", request["attachmentList"])
}
attachment, ok := attachments[0].(map[string]any)
if !ok {
t.Fatalf("attachment = %#v", attachments[0])
}
if got, _ := attachment["fileName"].(string); got != spelling.want {
t.Fatalf("fileName = %q, want %q; payload = %#v", got, spelling.want, call.args)
}
})
}
})
}
func nativePrimaryErrorMentionsFlag(message, name string) bool {
return strings.Contains(message, "--"+name) || strings.Contains(message, fmt.Sprintf("%q", name))
}
func assertNativePrimaryErrorHint(t *testing.T, err error, primary, legacy string) {
t.Helper()
if err == nil {
t.Fatalf("missing --%s returned nil", primary)
}
message := err.Error()
if !nativePrimaryErrorMentionsFlag(message, primary) {
t.Fatalf("error %q does not recommend --%s", message, primary)
}
if nativePrimaryErrorMentionsFlag(message, legacy) {
t.Fatalf("error %q still recommends legacy --%s", message, legacy)
}
}
func TestNativePrimaryParamMissingErrorsRecommendPrimaryOnly(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
installScriptedCaller(t, &scriptedToolCaller{})
assertNativePrimaryErrorHint(t, executeFilterCoverage(t, newAisearchCommand(), "person"), "query", "keyword")
os.Args = []string{"dws", "chat"}
assertNativePrimaryErrorHint(t,
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "send", "--group", "cid"),
"content", "text")
assertNativePrimaryErrorHint(t,
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "send-by-webhook", "--token", "token", "--title", "title"),
"content", "text")
assertNativePrimaryErrorHint(t,
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "reply", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--content", "body"),
"group", "conversation-id")
assertNativePrimaryErrorHint(t,
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "reply", "--group", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID),
"content", "text")
os.Args = []string{"dws", "doc"}
assertNativePrimaryErrorHint(t,
runDocCoverageCommand(t, &nativePrimaryParamCaller{}, "block", "insert", "--node", "node"),
"content", "text")
assertNativePrimaryErrorHint(t,
runDocCoverageCommand(t, &nativePrimaryParamCaller{}, "block", "update", "--node", "node", "--block-id", "block"),
"content", "text")
os.Args = []string{"dws", "todo"}
assertNativePrimaryErrorHint(t,
executeTodoEdge(t, &scriptedToolCaller{}, "task", "add-attachment", "--task-id", "42"),
"file", "file-path")
}
func TestNativePrimaryParamPromotionErrorsPropagate(t *testing.T) {
chat := newChatCommand()
assertPromotionError := func(t *testing.T, err error) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), "trying to get string value of flag of type bool") {
t.Fatalf("promotion error = %v", err)
}
}
t.Run("webhook RunE", func(t *testing.T) {
target := findNativePrimaryLeaf(t, chat, "message", "send-by-webhook")
cmd := &cobra.Command{}
cmd.Flags().String("content", "", "")
cmd.Flags().Bool("text", false, "")
if err := cmd.Flags().Set("text", "true"); err != nil {
t.Fatal(err)
}
assertPromotionError(t, target.RunE(cmd, nil))
})
reply := findNativePrimaryLeaf(t, chat, "message", "reply")
newInvalidGroupCommand := func(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{}
cmd.Flags().String("group", "", "")
cmd.Flags().Bool("conversation-id", false, "")
if err := cmd.Flags().Set("conversation-id", "true"); err != nil {
t.Fatal(err)
}
return cmd
}
t.Run("reply PreRunE group", func(t *testing.T) {
assertPromotionError(t, reply.PreRunE(newInvalidGroupCommand(t), nil))
})
t.Run("reply RunE group", func(t *testing.T) {
assertPromotionError(t, reply.RunE(newInvalidGroupCommand(t), nil))
})
t.Run("reply RunE content", func(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("group", "cid", "")
cmd.Flags().String("conversation-id", "", "")
cmd.Flags().String("content", "", "")
cmd.Flags().Bool("text", false, "")
if err := cmd.Flags().Set("text", "true"); err != nil {
t.Fatal(err)
}
assertPromotionError(t, reply.RunE(cmd, nil))
})
}
+1
View File
@@ -304,5 +304,6 @@ func newSheetCommentCmd() *cobra.Command {
}
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd)
commentCmd.AddCommand(newCommentBaseCommands("sheet")...)
return commentCmd
}
+20 -10
View File
@@ -11,11 +11,11 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
@@ -1017,14 +1017,20 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
Short: "上传待办附件",
Long: `上传待办附件
⚠️ 重要:该接口会上传文件到附件,不可用于测试或试探性调用。调用前必须确认待办存在。`,
Example: ` dws todo task add-attachment --task-id <taskId> --file-path <filePath>
Example: ` dws todo task add-attachment --task-id <taskId> --file <filePath>
# 查询 taskId: dws todo task list`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "task-id", "file-path"); err != nil {
return err
filePath := flagOrFallback(cmd, "file-path", "file")
taskID := mustGetFlag(cmd, "task-id")
if taskID == "" && filePath == "" {
return validateRequiredFlags(cmd, "task-id", "file")
}
if taskID == "" {
return validateRequiredFlags(cmd, "task-id")
}
if filePath == "" {
return validateRequiredFlags(cmd, "file")
}
filePath, _ := cmd.Flags().GetString("file-path")
meta, err := buildTodoLocalFileMeta(filePath, "", "")
if err != nil {
return err
@@ -1092,8 +1098,8 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
"要添加执行人/参与人/提醒时改用对应 add 命令",
},
Examples: []string{
"dws todo task add-attachment --task-id <taskId> --file-path /path/to/file.pdf",
"dws todo task add-attachment --task-id <taskId> --file-path /path/to/file.pdf --format json",
"dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf",
"dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf --format json",
},
},
Parameters: []contract.ParamDecl{
@@ -1247,7 +1253,11 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作
todoTaskListSubCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskListAttachmentCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskAddAttachment.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskAddAttachment.Flags().String("file-path", "", "本地文件路径(必填)")
corecmd.RegisterFlags(todoTaskAddAttachment, []corecmd.FlagSpec{{
Name: "file",
Usage: "本地文件路径(必填)",
Aliases: []string{"file-path"},
}})
todoTaskRemoveAttachmentCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskRemoveAttachmentCmd.Flags().String("attachment-id", "", "待办附件 ID(必填)")
+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))
}
}
+10 -10
View File
@@ -49,12 +49,12 @@ var MessagesSendByBot = shortcut.Shortcut{
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true, Aliases: []string{"text"}},
{Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId 列表"},
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId 列表"},
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"},
},
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --text "## 今日完成"`},
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --content "## 今日完成"`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
return err
@@ -63,7 +63,7 @@ var MessagesSendByBot = shortcut.Shortcut{
"robotCode": rt.Str("robot-code"),
"openConversationId": rt.Str("group"),
"title": rt.Str("title"),
"markdown": rt.Str("text"),
"markdown": rt.StrFirst("text", "content"),
}
if v := rt.StrSlice("at-user-ids"); len(v) > 0 {
params["atUserIds"] = v
@@ -90,12 +90,12 @@ var MessagesBatchSendByBot = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "Markdown 正文", Required: true, Aliases: []string{"text"}},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 userId 列表"},
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表"},
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"},
},
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --text "请提交周报"`},
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --content "请提交周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--open-dingtalk-ids", rt.StrSlice("open-dingtalk-ids")); err != nil {
return err
@@ -103,7 +103,7 @@ var MessagesBatchSendByBot = shortcut.Shortcut{
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"title": rt.Str("title"),
"markdown": rt.Str("text"),
"markdown": rt.StrFirst("text", "content"),
}
if v := rt.StrSlice("users"); len(v) > 0 {
params["userIds"] = v
@@ -148,23 +148,23 @@ var MessagesSendByWebhook = shortcut.Shortcut{
AgentSummary: "兼容旧入口的自定义机器人 Webhook 群消息发送",
UseWhen: []string{"只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +messages-send-by-webhook --token <token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
Examples: []string{"dws chat +messages-send-by-webhook --token <token> --title \"告警\" --content \"CPU 超 90%\" --at-all"},
},
},
Flags: []shortcut.Flag{
{Name: "token", Type: shortcut.FlagString, Desc: "Webhook token", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息正文", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "消息正文", Required: true, Aliases: []string{"text"}},
{Name: "at-all", Type: shortcut.FlagBool, Desc: "@ 所有人"},
{Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号列表"},
{Name: "at-users", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId 列表"},
},
Tips: []string{`dws chat +messages-send-by-webhook --token <token> --title "告警" --text "CPU 超 90%" --at-all`},
Tips: []string{`dws chat +messages-send-by-webhook --token <token> --title "告警" --content "CPU 超 90%" --at-all`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"robotToken": rt.Str("token"),
"title": rt.Str("title"),
"text": rt.Str("text"),
"text": rt.StrFirst("text", "content"),
}
if rt.Bool("at-all") {
params["isAtAll"] = true
+11 -7
View File
@@ -200,11 +200,11 @@ var MessagesReply = shortcut.Shortcut{
Intent: "当你要以当前用户身份对一条已有消息发送纯文本引用回复时使用;传会话和原消息 ID,CLI 会先读取原发送者,也可显式传 --ref-sender。成功结果在保留下层响应的同时增量返回 messageId(下层提供时)、conversationId、threadId(适用时)、deliveryStatus、idempotencyKey 和 referencedMessage 来源上下文。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "group", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true, Aliases: []string{"conversation-id"}},
{Name: "ref-msg-id", Type: shortcut.FlagString, Desc: "被引用消息 openMessageId"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "--ref-msg-id 的 lark-cli 对齐别名"},
{Name: "ref-sender", Type: shortcut.FlagString, Desc: "原消息发送者 openDingTalkId/userId(userId 通过通讯录搜索精确匹配;不传则自动读取)"},
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本回复内容", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "纯文本回复内容", Required: true, Aliases: []string{"text"}},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名"},
shortcut.AIMessageTagFlag(),
@@ -213,7 +213,7 @@ var MessagesReply = shortcut.Shortcut{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"ref-msg-id", "message-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
},
Tips: []string{`dws chat +messages-reply --conversation-id <openConversationId> --message-id <openMessageId> --text "收到" --idempotency-key <key>`},
Tips: []string{`dws chat +messages-reply --group <openConversationId> --message-id <openMessageId> --content "收到" --idempotency-key <key>`},
Execute: func(rt *shortcut.RuntimeContext) error {
refSender, err := resolveReplySender(rt)
if err != nil {
@@ -223,10 +223,10 @@ var MessagesReply = shortcut.Shortcut{
"referenceOpenMessageId": replyMessageID(rt),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": rt.Str("text"),
"content": rt.StrFirst("text", "content"),
})
params := rt.AddAIMessageTag(map[string]any{
"openConversationId": rt.Str("conversation-id"),
"openConversationId": replyConversationID(rt),
"msgType": "reply",
"content": string(content),
})
@@ -240,7 +240,7 @@ var MessagesReply = shortcut.Shortcut{
"willSend": false,
"transport": "chat/send_personal_message",
"arguments": params,
"conversationId": rt.Str("conversation-id"),
"conversationId": replyConversationID(rt),
"referencedMessage": map[string]any{
"messageId": replyMessageID(rt),
"senderOpenDingTalkId": refSender,
@@ -256,9 +256,13 @@ var MessagesReply = shortcut.Shortcut{
},
}
func replyConversationID(rt *shortcut.RuntimeContext) string {
return rt.StrFirst("conversation-id", "group")
}
func enrichReplyResult(data map[string]any, rt *shortcut.RuntimeContext, refSender string) {
data["contractVersion"] = "im.message-reply.v1"
data["conversationId"] = rt.Str("conversation-id")
data["conversationId"] = replyConversationID(rt)
data["referencedMessage"] = map[string]any{
"messageId": replyMessageID(rt),
"senderOpenDingTalkId": refSender,
@@ -0,0 +1,327 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestPrimaryParamMigrationChatContentPayloadCompatibility(t *testing.T) {
commands := []struct {
command string
baseArgs []string
wantProduct string
wantTool string
payloadKey string
}{
{
command: "+messages-send-by-bot",
baseArgs: []string{"--robot-code", "robot", "--group", "cid", "--title", "title"},
wantProduct: "bot",
wantTool: "send_robot_group_message",
payloadKey: "markdown",
},
{
command: "+messages-batch-send-by-bot",
baseArgs: []string{"--robot-code", "robot", "--users", "u1", "--title", "title"},
wantProduct: "bot",
wantTool: "batch_send_robot_msg_to_users",
payloadKey: "markdown",
},
{
command: "+messages-send-by-webhook",
baseArgs: []string{"--token", "token", "--title", "title"},
wantProduct: "bot",
wantTool: "send_message_by_custom_robot",
payloadKey: "text",
},
}
spellings := []struct {
name string
args []string
wantValue string
}{
{name: "old-only", args: []string{"--text", "legacy-value"}, wantValue: "legacy-value"},
{name: "new-only", args: []string{"--content", "legacy-value"}, wantValue: "legacy-value"},
{
name: "both-different-old-wins",
args: []string{"--text", "legacy-value", "--content", "canonical-value"},
wantValue: "legacy-value",
},
}
for _, command := range commands {
payloads := make(map[string]map[string]any, len(spellings))
for _, spelling := range spellings {
t.Run(command.command+"/"+spelling.name, func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
args := append([]string{"chat", command.command}, command.baseArgs...)
args = append(args, spelling.args...)
args = append(args, "--yes")
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("execute %v: %v", args, err)
}
if len(fake.calls) != 1 {
t.Fatalf("calls = %#v, want one", fake.calls)
}
call := fake.calls[0]
if call.product != command.wantProduct || call.tool != command.wantTool {
t.Fatalf("call = %s/%s, want %s/%s", call.product, call.tool, command.wantProduct, command.wantTool)
}
if got := call.args[command.payloadKey]; got != spelling.wantValue {
t.Fatalf("%s = %#v, want %q; args=%#v", command.payloadKey, got, spelling.wantValue, call.args)
}
payloads[spelling.name] = call.args
})
}
if !reflect.DeepEqual(payloads["old-only"], payloads["new-only"]) {
t.Errorf("%s old/new payloads differ: old=%#v new=%#v", command.command, payloads["old-only"], payloads["new-only"])
}
if !reflect.DeepEqual(payloads["old-only"], payloads["both-different-old-wins"]) {
t.Errorf("%s old/both payloads differ: old=%#v both=%#v", command.command, payloads["old-only"], payloads["both-different-old-wins"])
}
}
}
func TestPrimaryParamMigrationMessagesReplyPayloadCompatibility(t *testing.T) {
spellings := []struct {
name string
args []string
wantConversation string
wantContent string
}{
{
name: "old-only",
args: []string{"--conversation-id", "legacy-cid", "--text", "legacy-body"},
wantConversation: "legacy-cid",
wantContent: "legacy-body",
},
{
name: "new-only",
args: []string{"--group", "legacy-cid", "--content", "legacy-body"},
wantConversation: "legacy-cid",
wantContent: "legacy-body",
},
{
name: "both-different-old-wins",
args: []string{
"--conversation-id", "legacy-cid", "--group", "canonical-cid",
"--text", "legacy-body", "--content", "canonical-body",
},
wantConversation: "legacy-cid",
wantContent: "legacy-body",
},
}
payloads := make(map[string]map[string]any, len(spellings))
for _, spelling := range spellings {
t.Run(spelling.name, func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/send_personal_message": `{"result":{"openMessageId":"new-msg"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := []string{
"chat", "+messages-reply", "--message-id", "msg",
"--ref-sender", fixtureCurrentDOpenID,
}
args = append(args, spelling.args...)
args = append(args, "--yes")
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("execute %v: %v", args, err)
}
if len(fake.calls) != 1 {
t.Fatalf("calls = %#v, want one", fake.calls)
}
call := fake.calls[0]
if call.product != "chat" || call.tool != "send_personal_message" {
t.Fatalf("reply call = %#v", call)
}
assertReplyPrimaryPayload(t, call.args, spelling.wantConversation, spelling.wantContent)
payloads[spelling.name] = call.args
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode reply output %q: %v", output.String(), err)
}
if got := payload["conversationId"]; got != spelling.wantConversation {
t.Fatalf("result conversationId = %#v, want %q; payload=%#v", got, spelling.wantConversation, payload)
}
})
}
if !reflect.DeepEqual(payloads["old-only"], payloads["new-only"]) {
t.Errorf("reply old/new payloads differ: old=%#v new=%#v", payloads["old-only"], payloads["new-only"])
}
if !reflect.DeepEqual(payloads["old-only"], payloads["both-different-old-wins"]) {
t.Errorf("reply old/both payloads differ: old=%#v both=%#v", payloads["old-only"], payloads["both-different-old-wins"])
}
t.Run("dry-run-both-different-old-wins", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-reply", "--message-id", "msg", "--ref-sender", fixtureCurrentDOpenID,
"--conversation-id", "legacy-cid", "--group", "canonical-cid",
"--text", "legacy-body", "--content", "canonical-body",
"--dry-run", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("dry-run reached transport: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run output %q: %v", output.String(), err)
}
if got := payload["conversationId"]; got != "legacy-cid" {
t.Fatalf("dry-run conversationId = %#v, want legacy-cid", got)
}
arguments, _ := payload["arguments"].(map[string]any)
assertReplyPrimaryPayload(t, arguments, "legacy-cid", "legacy-body")
})
}
func assertReplyPrimaryPayload(t *testing.T, args map[string]any, wantConversation, wantContent string) {
t.Helper()
if got := args["openConversationId"]; got != wantConversation {
t.Fatalf("openConversationId = %#v, want %q; args=%#v", got, wantConversation, args)
}
contentText, _ := args["content"].(string)
var content map[string]string
if err := json.Unmarshal([]byte(contentText), &content); err != nil {
t.Fatalf("decode reply content %q: %v", contentText, err)
}
if got := content["content"]; got != wantContent {
t.Fatalf("reply content = %q, want %q; payload=%#v", got, wantContent, content)
}
}
func TestPrimaryParamMigrationChatSurfaceAndContract(t *testing.T) {
tests := []struct {
command string
params []chatPrimaryParamExpectation
}{
{
command: "+messages-send-by-bot",
params: []chatPrimaryParamExpectation{{name: "content", legacy: "text", property: "text"}},
},
{
command: "+messages-batch-send-by-bot",
params: []chatPrimaryParamExpectation{{name: "content", legacy: "text", property: "text"}},
},
{
command: "+messages-send-by-webhook",
params: []chatPrimaryParamExpectation{{name: "content", legacy: "text", property: "text"}},
},
{
command: "+messages-reply",
params: []chatPrimaryParamExpectation{
{name: "group", legacy: "conversation-id", property: "conversationId"},
{name: "content", legacy: "text", property: "text"},
},
},
}
for _, test := range tests {
t.Run(test.command, func(t *testing.T) {
root := newPlatformCoverageRoot()
cmd, _, err := root.Find([]string{"chat", test.command})
if err != nil || cmd == nil {
t.Fatalf("find command: %v", err)
}
for _, param := range test.params {
canonical := cmd.Flags().Lookup(param.name)
if canonical == nil || canonical.Hidden {
t.Fatalf("canonical --%s = %#v, want visible", param.name, canonical)
}
legacy := cmd.Flags().Lookup(param.legacy)
if legacy == nil || !legacy.Hidden {
t.Fatalf("legacy --%s = %#v, want hidden", param.legacy, legacy)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != param.name {
t.Fatalf("--%s alias_of = %#v, want %s", param.legacy, got, param.name)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("--%s alias origin = %#v", param.legacy, got)
}
}
var help bytes.Buffer
cmd.SetOut(&help)
if err := cmd.Help(); err != nil {
t.Fatal(err)
}
for _, param := range test.params {
if !strings.Contains(help.String(), "--"+param.name) || strings.Contains(help.String(), "--"+param.legacy) {
t.Fatalf("help does not expose only --%s (legacy --%s):\n%s", param.name, param.legacy, help.String())
}
}
spec := chatRegisteredShortcut(t, test.command)
if len(spec.Contract.Parameters) != len(test.params) {
t.Fatalf("ParamDecls = %#v, want %d", spec.Contract.Parameters, len(test.params))
}
for i, want := range test.params {
param := spec.Contract.Parameters[i]
if param.Name != want.name || param.Property != want.property ||
param.Required == nil || !*param.Required || param.InterfaceType != "" {
t.Fatalf("ParamDecl[%d] = %#v, want %#v", i, param, want)
}
}
for _, example := range append(append([]string(nil), spec.Tips...), spec.Contract.Selection.Examples...) {
for _, param := range test.params {
if strings.Contains(example, "--"+param.legacy) || !strings.Contains(example, "--"+param.name) {
t.Fatalf("example does not recommend only --%s: %q", param.name, example)
}
}
}
})
}
}
type chatPrimaryParamExpectation struct {
name string
legacy string
property string
}
func chatRegisteredShortcut(t *testing.T, command string) shortcut.Shortcut {
t.Helper()
for _, spec := range shortcut.All() {
if spec.Service == "chat" && spec.Command == command {
return spec
}
}
t.Fatalf("registered shortcut chat %s not found", command)
return shortcut.Shortcut{}
}
+35 -7
View File
@@ -87,18 +87,46 @@ func withReviewedChatShortcutContracts(values ...shortcut.Shortcut) []shortcut.S
out := make([]shortcut.Shortcut, len(values))
for i, value := range values {
out[i] = value
if !value.Contract.Empty() {
continue
if value.Contract.Empty() {
if _, reviewed := reviewedChatShortcutContractCommands[value.Command]; reviewed {
out[i].Safety = reviewedChatShortcutSafety(value.Risk)
out[i].Contract = reviewedChatShortcutContract(value)
}
}
if _, reviewed := reviewedChatShortcutContractCommands[value.Command]; !reviewed {
continue
}
out[i].Safety = reviewedChatShortcutSafety(value.Risk)
out[i].Contract = reviewedChatShortcutContract(value)
out[i].Contract.Parameters = append(
out[i].Contract.Parameters,
reviewedChatPrimaryParamDecls(value.Command)...,
)
}
return out
}
func reviewedChatPrimaryParamDecls(command string) []contract.ParamDecl {
switch command {
case "+messages-send-by-bot", "+messages-batch-send-by-bot", "+messages-send-by-webhook":
return []contract.ParamDecl{renamedRequiredChatParam("content", "text")}
case "+messages-reply":
return []contract.ParamDecl{
renamedRequiredChatParam("group", "conversationId"),
renamedRequiredChatParam("content", "text"),
}
default:
return nil
}
}
// renamedRequiredChatParam preserves the pre-rename Schema property and
// requiredness. InterfaceType stays unset because these Shortcut parameters
// historically published only their Cobra string type.
func renamedRequiredChatParam(name, property string) contract.ParamDecl {
required := true
return contract.ParamDecl{
Name: name,
Property: property,
Required: &required,
}
}
func reviewedChatShortcutSafety(risk shortcut.Risk) contract.SafetySpec {
switch risk {
case shortcut.RiskWrite:
@@ -124,18 +124,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"calendar\u0000+book-search": {},
"calendar\u0000+cancel-event": {},
"calendar\u0000+conflicts": {},
"calendar\u0000+create": {},
"calendar\u0000+free": {},
"calendar\u0000+free-slots": {},
"calendar\u0000+freebusy": {},
"calendar\u0000+get": {},
"calendar\u0000+invite": {},
"calendar\u0000+my-free": {},
"calendar\u0000+next-event": {},
"calendar\u0000+reschedule": {},
"calendar\u0000+room-find": {},
"calendar\u0000+room-groups": {},
"calendar\u0000+room-search": {},
"calendar\u0000+rsvp": {},
"calendar\u0000+search-event": {},
"calendar\u0000+suggest-time": {},
"calendar\u0000+suggestion": {},
"calendar\u0000+today": {},
"calendar\u0000+tomorrow": {},
"calendar\u0000+update": {},
"calendar\u0000+week": {},
"chat\u0000+at-me": {},
"chat\u0000+bot-find": {},
@@ -395,15 +402,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"sheet\u0000+read": {},
"todo\u0000+assign": {},
"todo\u0000+assign-multi": {},
"todo\u0000+comment": {},
"todo\u0000+complete": {},
"todo\u0000+create": {},
"todo\u0000+created-todos": {},
"todo\u0000+due-today": {},
"todo\u0000+get": {},
"todo\u0000+get-my-tasks": {},
"todo\u0000+get-related-tasks": {},
"todo\u0000+list-attachment": {},
"todo\u0000+list-comment": {},
"todo\u0000+list-sub": {},
"todo\u0000+overdue": {},
"todo\u0000+related-tasks": {},
"todo\u0000+remind": {},
"todo\u0000+reminder": {},
"todo\u0000+reopen": {},
"todo\u0000+search": {},
"todo\u0000+todo-done": {},
"todo\u0000+update": {},
"wiki\u0000+delete-space": {},
"wiki\u0000+feed-list": {},
"wiki\u0000+member-add": {},
+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)
}
+6 -5
View File
@@ -34,7 +34,7 @@ import (
// without aborting delivery to the others. Replaces manually running
// `chat +dm` once per recipient.
//
// dws chat +broadcast --to "张三,李四,王五" --text "今晚 8 点上线,请留意群公告"
// dws chat +broadcast --to "张三,李四,王五" --content "今晚 8 点上线,请留意群公告"
var Broadcast = shortcut.Shortcut{
Service: "chat",
Command: "+broadcast",
@@ -66,17 +66,18 @@ var Broadcast = shortcut.Shortcut{
AgentSummary: "按姓名逐一给多个人群发同一条单聊消息(自动解析 userId、逐个发送)",
UseWhen: []string{"当你想把同一条通知一次性单聊发给多位同事、但只知道他们的姓名不想逐个查 userId 时使用;内部把姓名列表逐个解析成唯一用户后,用 openDingTalkId 对每个人单独发一条单聊消息,并汇总成功/失败人数。某个姓名匹配不到人或匹配到多人时,会跳过该人并在结尾报出,不影响其他人收到消息。会真实发出多条消息。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +broadcast --to \"张三,李四,王五\" --text \"今晚 8 点上线,请留意\""},
Examples: []string{"dws chat +broadcast --to \"张三,李四,王五\" --content \"今晚 8 点上线,请留意\""},
},
Parameters: []contract.ParamDecl{renamedRequiredParam("content", "text")},
},
Flags: []shortcut.Flag{
{Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件人姓名/花名,逗号分隔的多个人", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown),所有人收到同一条", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown),所有人收到同一条", Required: true, Aliases: []string{"text"}},
shortcut.AIMessageTagFlag(),
},
Tips: []string{`dws chat +broadcast --to "张三,李四,王五" --text "今晚 8 点上线,请留意"`},
Tips: []string{`dws chat +broadcast --to "张三,李四,王五" --content "今晚 8 点上线,请留意"`},
Execute: func(rt *shortcut.RuntimeContext) error {
text := rt.Str("text")
text := rt.StrFirst("text", "content")
names := rt.StrSlice("to")
if len(names) == 0 {
return apperrors.NewValidation("--to 至少要包含一个姓名")
+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",
+6 -5
View File
@@ -31,7 +31,7 @@ import (
// a single-chat message via openDingTalkId. Replaces `contact +search-user`
// (copy openDingTalkId) → `chat +messages-send --open-dingtalk-id <id>`.
//
// dws chat +dm --to 张三 --text "周报发我一下"
// dws chat +dm --to 张三 --content "周报发我一下"
var DM = shortcut.Shortcut{
Service: "chat",
Command: "+dm",
@@ -62,17 +62,18 @@ var DM = shortcut.Shortcut{
AgentSummary: "按姓名直接给某人发单聊消息(自动解析唯一 openDingTalkId)",
UseWhen: []string{"当你只知道对方姓名、想直接发一条单聊消息而不想先查 userId 时使用;内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +dm --to 张三 --text \"周报发我一下\""},
Examples: []string{"dws chat +dm --to 张三 --content \"周报发我一下\""},
},
Parameters: []contract.ParamDecl{renamedRequiredParam("content", "text")},
},
Flags: []shortcut.Flag{
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true, Aliases: []string{"text"}},
shortcut.AIMessageTagFlag(),
},
Tips: []string{`dws chat +dm --to 张三 --text "周报发我一下"`},
Tips: []string{`dws chat +dm --to 张三 --content "周报发我一下"`},
Execute: func(rt *shortcut.RuntimeContext) error {
text := rt.Str("text")
text := rt.StrFirst("text", "content")
resolved, err := targetresolver.ResolveUser(
rt,
+9 -8
View File
@@ -33,7 +33,7 @@ import (
// touching existing content. Params (nodeId/markdown/mode) are copied verbatim
// from the update_document append call site in internal/helpers/doc.go.
//
// dws doc +doc-append --doc DOC_ID --text "今天的会议结论:下周一上线。"
// dws doc +doc-append --doc DOC_ID --content "今天的会议结论:下周一上线。"
var DocAppend = shortcut.Shortcut{
Service: "doc",
Command: "+doc-append",
@@ -67,27 +67,28 @@ var DocAppend = shortcut.Shortcut{
UseWhen: []string{"当你只想往一篇钉钉文档的最后面补一段文字、又不想动原有内容时使用;内部用文档更新的“追加(append)”模式,把你给的文本安全地拼到文档末尾,不需要你先去查文档块列表、算末尾位置或手工拼块结构。会真实写入文档内容。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws doc +doc-append --doc DOC_ID --text \"补充说明:本方案已评审通过。\"",
"dws doc +doc-append --doc \"https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>\" --text \"追加一行备注\"",
"dws doc +doc-append --doc DOC_ID --content \"补充说明:本方案已评审通过。\"",
"dws doc +doc-append --doc \"https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>\" --content \"追加一行备注\"",
},
},
Parameters: []contract.ParamDecl{renamedRequiredParam("content", "text")},
},
Flags: []shortcut.Flag{
{Name: "doc", Type: shortcut.FlagString, Desc: "文档 documentId / nodeId(或文档 URL/token)", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "要追加到文档末尾的文本", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "要追加到文档末尾的文本", Required: true, Aliases: []string{"text"}},
},
Tips: []string{
`dws doc +doc-append --doc DOC_ID --text "补充说明:本方案已评审通过。"`,
`dws doc +doc-append --doc "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --text "追加一行备注"`,
`dws doc +doc-append --doc DOC_ID --content "补充说明:本方案已评审通过。"`,
`dws doc +doc-append --doc "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --content "追加一行备注"`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
nodeID := strings.TrimSpace(rt.Str("doc"))
if nodeID == "" {
return apperrors.NewValidation("--doc 不能为空,请提供文档 documentId/nodeId 或文档 URL")
}
text := rt.Str("text")
text := rt.StrFirst("text", "content")
if strings.TrimSpace(text) == "" {
return apperrors.NewValidation("--text 不能为空,请提供要追加的文本")
return apperrors.NewValidation("--content 不能为空,请提供要追加的文本")
}
// update_document append: params copied verbatim from the helper's
+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",
@@ -0,0 +1,28 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
// renamedRequiredParam preserves the pre-rename Schema property and
// requiredness. InterfaceType intentionally remains unset because these
// Shortcut parameters historically published only their Cobra string type.
func renamedRequiredParam(name, property string) contract.ParamDecl {
required := true
return contract.ParamDecl{
Name: name,
Property: property,
Required: &required,
}
}
@@ -0,0 +1,215 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
func TestPrimaryParamMigrationSmartPayloadCompatibility(t *testing.T) {
commands := []struct {
name string
baseArgs []string
wantProduct string
wantTool string
payloadKey string
}{
{
name: "broadcast",
baseArgs: []string{"chat", "+broadcast", "--to", "张三"},
wantProduct: "chat",
wantTool: "send_personal_message",
payloadKey: "content",
},
{
name: "dm",
baseArgs: []string{"chat", "+dm", "--to", "张三"},
wantProduct: "chat",
wantTool: "send_personal_message",
payloadKey: "content",
},
{
name: "send-to-group",
baseArgs: []string{"chat", "+send-to-group", "--group", "项目冲刺"},
wantProduct: "chat",
wantTool: "send_personal_message",
payloadKey: "content",
},
{
name: "doc-append",
baseArgs: []string{"doc", "+doc-append", "--doc", "doc-1"},
wantProduct: "doc",
wantTool: "update_document",
payloadKey: "markdown",
},
}
spellings := []struct {
name string
args []string
wantValue string
}{
{name: "old-only", args: []string{"--text", "legacy-value"}, wantValue: "legacy-value"},
{name: "new-only", args: []string{"--content", "legacy-value"}, wantValue: "legacy-value"},
{
name: "both-different-old-wins",
args: []string{"--text", "legacy-value", "--content", "canonical-value"},
wantValue: "legacy-value",
},
}
for _, command := range commands {
payloads := make(map[string]map[string]any, len(spellings))
for _, spelling := range spellings {
t.Run(command.name+"/"+spelling.name, func(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
args := append([]string(nil), command.baseArgs...)
args = append(args, spelling.args...)
args = append(args, "--yes")
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("execute %v: %v", args, err)
}
if len(fake.calls) == 0 {
t.Fatal("shortcut made no MCP call")
}
call := fake.calls[len(fake.calls)-1]
if call.product != command.wantProduct || call.tool != command.wantTool {
t.Fatalf("last call = %s/%s, want %s/%s", call.product, call.tool, command.wantProduct, command.wantTool)
}
if got := smartPrimaryPayloadValue(t, call.args, command.payloadKey); got != spelling.wantValue {
t.Fatalf("payload value = %q, want %q; args=%#v", got, spelling.wantValue, call.args)
}
payloads[spelling.name] = call.args
})
}
if !reflect.DeepEqual(payloads["old-only"], payloads["new-only"]) {
t.Errorf("%s old/new payloads differ: old=%#v new=%#v", command.name, payloads["old-only"], payloads["new-only"])
}
if !reflect.DeepEqual(payloads["old-only"], payloads["both-different-old-wins"]) {
t.Errorf("%s old/both payloads differ: old=%#v both=%#v", command.name, payloads["old-only"], payloads["both-different-old-wins"])
}
}
}
func smartPrimaryPayloadValue(t *testing.T, args map[string]any, key string) string {
t.Helper()
value, _ := args[key].(string)
if key == "markdown" {
return value
}
var content map[string]string
if err := json.Unmarshal([]byte(value), &content); err != nil {
t.Fatalf("decode markdown content %q: %v", value, err)
}
return content["text"]
}
func TestPrimaryParamMigrationSmartSurfaceAndContract(t *testing.T) {
tests := []struct {
service string
command string
}{
{service: "chat", command: "+broadcast"},
{service: "chat", command: "+dm"},
{service: "chat", command: "+send-to-group"},
{service: "doc", command: "+doc-append"},
}
root := newPlatformCoverageRoot()
for _, test := range tests {
t.Run(test.service+"/"+test.command, func(t *testing.T) {
cmd, _, err := root.Find([]string{test.service, test.command})
if err != nil || cmd == nil {
t.Fatalf("find command: %v", err)
}
canonical := cmd.Flags().Lookup("content")
if canonical == nil || canonical.Hidden {
t.Fatalf("canonical --content = %#v, want visible", canonical)
}
legacy := cmd.Flags().Lookup("text")
if legacy == nil || !legacy.Hidden {
t.Fatalf("legacy --text = %#v, want hidden", legacy)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "content" {
t.Fatalf("--text alias_of = %#v, want content", got)
}
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("--text alias origin = %#v", got)
}
var help bytes.Buffer
cmd.SetOut(&help)
if err := cmd.Help(); err != nil {
t.Fatal(err)
}
if !strings.Contains(help.String(), "--content") || strings.Contains(help.String(), "--text") {
t.Fatalf("help does not expose only the new Primary:\n%s", help.String())
}
spec := smartRegisteredShortcut(t, test.service, test.command)
if len(spec.Contract.Parameters) != 1 {
t.Fatalf("ParamDecls = %#v, want one", spec.Contract.Parameters)
}
param := spec.Contract.Parameters[0]
if param.Name != "content" || param.Property != "text" ||
param.Required == nil || !*param.Required || param.InterfaceType != "" {
t.Fatalf("content ParamDecl = %#v", param)
}
for _, example := range append(append([]string(nil), spec.Tips...), spec.Contract.Selection.Examples...) {
if strings.Contains(example, "--text") || !strings.Contains(example, "--content") {
t.Fatalf("example does not recommend only --content: %q", example)
}
}
})
}
}
func TestPrimaryParamMigrationDocAppendRejectsBlankContent(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("doc", "", "")
cmd.Flags().String("content", "", "")
cmd.Flags().String("text", "", "")
if err := cmd.Flags().Set("doc", "doc-1"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("content", " "); err != nil {
t.Fatal(err)
}
err := DocAppend.Execute(shortcut.RuntimeContextForTest(cmd, DocAppend))
if err == nil || !strings.Contains(err.Error(), "--content 不能为空") {
t.Fatalf("blank --content error = %v", err)
}
}
func smartRegisteredShortcut(t *testing.T, service, command string) shortcut.Shortcut {
t.Helper()
for _, spec := range shortcut.All() {
if spec.Service == service && spec.Command == command {
return spec
}
}
t.Fatalf("registered shortcut %s %s not found", service, command)
return shortcut.Shortcut{}
}
+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)
}
})
+6 -5
View File
@@ -33,7 +33,7 @@ import (
// group NAMES) — NOT `search_common_groups`, which searches by member nicknames
// and cannot locate a group by its title.
//
// dws chat +send-to-group --group 项目冲刺 --text "今天 5 点前提交进度"
// dws chat +send-to-group --group 项目冲刺 --content "今天 5 点前提交进度"
var SendToGroup = shortcut.Shortcut{
Service: "chat",
Command: "+send-to-group",
@@ -63,18 +63,19 @@ var SendToGroup = shortcut.Shortcut{
AgentSummary: "按群名或 openConversationId 直接给群发消息",
UseWhen: []string{"当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +send-to-group --group 项目冲刺 --text \"今天 5 点前提交进度\""},
Examples: []string{"dws chat +send-to-group --group 项目冲刺 --content \"今天 5 点前提交进度\""},
},
Parameters: []contract.ParamDecl{renamedRequiredParam("content", "text")},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true, Aliases: []string{"text"}},
shortcut.AIMessageTagFlag(),
},
Tips: []string{`dws chat +send-to-group --group 项目冲刺 --text "今天 5 点前提交进度"`},
Tips: []string{`dws chat +send-to-group --group 项目冲刺 --content "今天 5 点前提交进度"`},
Execute: func(rt *shortcut.RuntimeContext) error {
groupName := rt.Str("group")
text := rt.Str("text")
text := rt.StrFirst("text", "content")
resolved, err := targetresolver.ResolveChatTarget(rt, groupName, "")
if err != nil {
@@ -0,0 +1,288 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"errors"
"strconv"
"strings"
"testing"
"time"
)
const (
smartCoverageStart = "2026-08-17T09:00:00+08:00"
smartCoverageEnd = "2026-08-17T10:00:00+08:00"
smartCoverageEvent = `{"success":true,"result":{"id":"event-placeholder","summary":"fixture title","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}}}`
smartTodoPageEmpty = `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
)
func smartContact() calendarSmartTestStep {
return calendarSmartTestStep{text: `{"result":[{"userId":"user-placeholder","name":"fixture person"}]}`}
}
func TestCrossPlatformCoverageCalendarSmartBookBranches(t *testing.T) {
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+book", "--title", "x", "--start", smartCoverageEnd, "--end", smartCoverageStart, "--yes"); err == nil {
t.Fatal("book accepted reversed range")
}
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+book", "--title", "x", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
t.Fatal(err)
}
blankWith := &calendarSmartTestCaller{}
if _, _, err := runCalendarSmartCLI(t, blankWith, "calendar", "+book", "--title", "x", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--with", " , ", "--yes"); err == nil {
t.Fatal("blank invitee list accepted")
}
for name, caller := range map[string]*calendarSmartTestCaller{
"create-call": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{err: errors.New("create")}}}},
"missing-id": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{text: `{"success":true,"result":{}}`}}}},
"read-call": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/get_calendar_detail": {{err: errors.New("read")}}}},
"read-shape": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/get_calendar_detail": {{text: `{"success":true,"result":{}}`}}}},
"read-verify": {steps: map[string][]calendarSmartTestStep{"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/get_calendar_detail": {{text: strings.Replace(smartCoverageEvent, "fixture title", "wrong", 1)}}}},
} {
t.Run(name, func(t *testing.T) {
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+book", "--title", "fixture title", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--yes"); err == nil {
t.Fatal("bad book path accepted")
}
})
}
rollbackFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"contact/search_contact_by_key_word": {{text: smartContact().text}},
"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}},
"calendar/add_calendar_participant": {{text: `{"success":false}`}},
"calendar/delete_calendar_event": {{err: errors.New("rollback")}},
}}
if _, _, err := runCalendarSmartCLI(t, rollbackFailure, "calendar", "+book", "--title", "fixture title", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--with", "fixture person", "--yes"); err == nil {
t.Fatal("failed rollback accepted")
}
participantCases := map[string]*calendarSmartTestCaller{
"call": {steps: map[string][]calendarSmartTestStep{
"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: smartCoverageEvent}}, "calendar/get_calendar_participants": {{err: errors.New("participants")}},
}},
"shape": {steps: map[string][]calendarSmartTestStep{
"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: smartCoverageEvent}}, "calendar/get_calendar_participants": {{text: `{"success":true}`}},
}},
"profile": {steps: map[string][]calendarSmartTestStep{
"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: smartCoverageEvent}}, "calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"displayName":"fixture person","self":true}]}`}}, "contact/get_current_user_profile": {{err: errors.New("profile")}},
}},
"missing": {steps: map[string][]calendarSmartTestStep{
"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: smartCoverageEvent}}, "calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"userId":"other"}]}`}},
}},
}
for name, caller := range participantCases {
t.Run("participants-"+name, func(t *testing.T) {
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+book", "--title", "fixture title", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--with", "fixture person", "--yes"); err == nil {
t.Fatal("bad participant verification accepted")
}
})
}
}
func TestCrossPlatformCoverageCalendarSmartInviteAndRescheduleBranches(t *testing.T) {
preflight := `{"success":true,"result":{"id":"event-placeholder"}}`
inviteDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}}}
if _, _, err := runCalendarSmartCLI(t, inviteDry, "calendar", "+invite", "--event", "event-placeholder", "--with", "fixture person", "--dry-run", "--yes"); err != nil {
t.Fatal(err)
}
for name, caller := range map[string]*calendarSmartTestCaller{
"preflight-call": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{err: errors.New("preflight")}}}},
"write-call": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{err: errors.New("write")}}}},
"receipt": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"result":{}}`}}}},
"participants-call": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_participants": {{err: errors.New("participants")}}}},
"participants-shape": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_participants": {{text: `{"success":true}`}}}},
"profile": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"displayName":"fixture person","self":true}]}`}}, "contact/get_current_user_profile": {{err: errors.New("profile")}}}},
"missing": {steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "calendar/get_calendar_detail": {{text: preflight}}, "calendar/add_calendar_participant": {{text: `{"success":true}`}}, "calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"userId":"other"}]}`}}}},
} {
t.Run("invite-"+name, func(t *testing.T) {
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+invite", "--event", "event-placeholder", "--with", "fixture person", "--yes"); err == nil {
t.Fatal("bad invite path accepted")
}
})
}
rescheduleDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}}}}
if _, _, err := runCalendarSmartCLI(t, rescheduleDry, "calendar", "+reschedule", "--event", "event-placeholder", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
t.Fatal(err)
}
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+reschedule", "--event", "event-placeholder", "--start", smartCoverageEnd, "--end", smartCoverageStart, "--yes"); err == nil {
t.Fatal("reschedule accepted reversed range")
}
for name, caller := range map[string]*calendarSmartTestCaller{
"preflight": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{err: errors.New("preflight")}}}},
"write": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}}, "calendar/update_calendar_event": {{err: errors.New("write")}}}},
"receipt": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}}, "calendar/update_calendar_event": {{text: `{"result":{}}`}}}},
"read": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}, {err: errors.New("read")}}, "calendar/update_calendar_event": {{text: `{"success":true}`}}}},
"shape": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}, {text: `{"success":true,"result":{}}`}}, "calendar/update_calendar_event": {{text: `{"success":true}`}}}},
"times": {steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: preflight}, {text: `{"success":true,"result":{"id":"event-placeholder","start":{"dateTime":"2026-08-17T08:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}}}`}}, "calendar/update_calendar_event": {{text: `{"success":true}`}}}},
} {
t.Run("reschedule-"+name, func(t *testing.T) {
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+reschedule", "--event", "event-placeholder", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--yes"); err == nil {
t.Fatal("bad reschedule path accepted")
}
})
}
}
func TestCrossPlatformCoverageCalendarSmartReadCommands(t *testing.T) {
if _, ok := conflictsEndTime(map[string]any{"endTime": smartCoverageEnd}); !ok {
t.Fatal("top-level endTime was not parsed")
}
conflictEvents := `{"success":true,"result":{"events":[{"id":"a","summary":"","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}},{"id":"b","summary":"B","start":{"dateTime":"2026-08-17T09:30:00+08:00"},"end":{"dateTime":"2026-08-17T10:30:00+08:00"}}],"hasMore":false}}`
for _, command := range []string{"+conflicts", "+free-slots", "+tomorrow", "+week"} {
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": {{text: conflictEvents}}}}
args := []string{"calendar", command}
if command == "+free-slots" {
args = append(args, "--from", "0", "--to", "24")
}
if _, _, err := runCalendarSmartCLI(t, caller, args...); err != nil {
t.Fatalf("%s: %v", command, err)
}
}
for name, response := range map[string]string{
"empty": smartTodoPageEmpty,
"future": `{"success":true,"result":{"events":[{"id":"future","summary":"future","start":{"dateTime":"2099-08-17T09:00:00+08:00"},"end":{"dateTime":"2099-08-17T10:00:00+08:00"}}],"hasMore":false}}`,
} {
t.Run("next-"+name, func(t *testing.T) {
if name == "empty" {
response = `{"success":true,"result":{"events":[],"hasMore":false}}`
}
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": {{text: response}}}}
if _, _, err := runCalendarSmartCLI(t, caller, "calendar", "+next-event"); err != nil {
t.Fatal(err)
}
})
}
if got := shortcutNextEventPick([]map[string]any{{"id": ""}, {"id": "x", "start": "bad"}}, time.Now()); got != nil {
t.Fatalf("invalid next event picked: %#v", got)
}
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+free-slots", "--from", "24", "--to", "1"); err == nil {
t.Fatal("bad free-slot window accepted")
}
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "calendar", "+suggest-time", "--with", "fixture person", "--start", smartCoverageEnd, "--end", smartCoverageStart); err == nil {
t.Fatal("suggest-time accepted reversed range")
}
suggest := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"contact/search_contact_by_key_word": {{text: smartContact().text}},
"calendar/list_suggested_event_times": {{text: `{"success":true,"result":{"recommendEventTimes":[]}}`}},
}}
if _, _, err := runCalendarSmartCLI(t, suggest, "calendar", "+suggest-time", "--with", "fixture person", "--start", smartCoverageStart, "--end", smartCoverageEnd, "--duration", "30"); err != nil {
t.Fatal(err)
}
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"event-placeholder"}}`}}}}, "calendar", "+cancel-event", "--event", "event-placeholder", "--dry-run", "--yes"); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageTodoSmartWriteBranches(t *testing.T) {
assignDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}}}
if _, _, err := runCalendarSmartCLI(t, assignDry, "todo", "+assign", "--task", "task", "--to", "fixture person", "--due", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
t.Fatal(err)
}
for name, responses := range map[string]map[string][]calendarSmartTestStep{
"call": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{err: errors.New("write")}}},
"verify": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"result":{}}`}}},
"success": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}},
} {
t.Run("assign-"+name, func(t *testing.T) {
_, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{steps: responses}, "todo", "+assign", "--task", "task", "--to", "fixture person", "--yes")
if name == "success" {
if err != nil {
t.Fatal(err)
}
} else if err == nil {
t.Fatal("bad assign path accepted")
}
})
}
multiDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}}}
if _, _, err := runCalendarSmartCLI(t, multiDry, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--dry-run", "--yes"); err != nil {
t.Fatal(err)
}
multiSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}}}
if _, _, err := runCalendarSmartCLI(t, multiSuccess, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--yes"); err != nil {
t.Fatal(err)
}
multiFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{err: errors.New("write")}}}}
if _, _, err := runCalendarSmartCLI(t, multiFailure, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--yes"); err == nil {
t.Fatal("assign-multi write failure accepted")
}
doneDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}}}
if _, _, err := runCalendarSmartCLI(t, doneDry, "todo", "+todo-done", "--task", "needle", "--dry-run", "--yes"); err != nil {
t.Fatal(err)
}
doneSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{text: `{"success":true}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","isDone":true}}}`}}}}
if _, _, err := runCalendarSmartCLI(t, doneSuccess, "todo", "+todo-done", "--task", "needle", "--yes"); err != nil {
t.Fatal(err)
}
doneCall := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{err: errors.New("write")}}}}
if _, _, err := runCalendarSmartCLI(t, doneCall, "todo", "+todo-done", "--task", "needle", "--yes"); err == nil {
t.Fatal("todo-done write failure accepted")
}
doneVerify := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{text: `{"result":{}}`}}}}
if _, _, err := runCalendarSmartCLI(t, doneVerify, "todo", "+todo-done", "--task", "needle", "--yes"); err == nil {
t.Fatal("todo-done unverifiable write accepted")
}
remindDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": {{text: `{"result":{"userId":"user-placeholder"}}`}}}}
if _, _, err := runCalendarSmartCLI(t, remindDry, "todo", "+remind", "--task", "task", "--at", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
t.Fatal(err)
}
remindSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": {{text: `{"result":{"userId":"user-placeholder"}}`}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}}}
if _, _, err := runCalendarSmartCLI(t, remindSuccess, "todo", "+remind", "--task", "task", "--yes"); err != nil {
t.Fatal(err)
}
for name, step := range map[string]calendarSmartTestStep{
"call": {err: errors.New("write")},
"receipt": {text: `{"result":{}}`},
} {
t.Run("remind-"+name, func(t *testing.T) {
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"contact/get_current_user_profile": {{text: `{"result":{"userId":"user-placeholder"}}`}},
"todo/create_personal_todo": {step},
}}
if _, _, err := runCalendarSmartCLI(t, caller, "todo", "+remind", "--task", "task", "--yes"); err == nil {
t.Fatal("bad remind write accepted")
}
})
}
}
func TestCrossPlatformCoverageTodoSmartReadAndSharedBranches(t *testing.T) {
for _, response := range []map[string]any{{}, {"success": false}, {"result": "bad"}, {"result": map[string]any{"todoCards": map[string]any{}}}} {
if _, _, err := shortcutTodoCardsStrict(response); err == nil {
t.Fatalf("bad todo cards accepted: %#v", response)
}
}
deep := map[string]any{"result": map[string]any{"result": map[string]any{"result": map[string]any{"result": map[string]any{"result": map[string]any{"result": map[string]any{}}}}}}}
if _, _, err := shortcutTodoCardsStrict(deep); err == nil {
t.Fatal("over-deep todo wrapper accepted")
}
steps := make([]calendarSmartTestStep, todoMaxPages)
for index := range steps {
steps[index] = calendarSmartTestStep{text: `{"success":true,"result":{"todoCards":[],"hasMore":true}}`}
}
capCaller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": steps}}
if _, _, err := runCalendarSmartCLI(t, capCaller, "todo", "+created-todos"); err == nil {
t.Fatal("todo page cap accepted incomplete data")
}
dayStart, dayEnd := calendarDayRange(0)
dueMillis := dayStart.Add(time.Hour).UnixMilli()
due := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"inside","dueTime":` + fmtInt64(dueMillis) + `},{"taskId":"task-out","subject":"outside","dueTime":` + fmtInt64(dayEnd.UnixMilli()) + `}],"hasMore":false}}`}}}}
if _, _, err := runCalendarSmartCLI(t, due, "todo", "+due-today"); err != nil {
t.Fatal(err)
}
badDue := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"bad"}],"hasMore":false}}`}}}}
if _, _, err := runCalendarSmartCLI(t, badDue, "todo", "+due-today"); err == nil {
t.Fatal("due-today accepted missing due time")
}
}
func fmtInt64(value int64) string {
return strconv.FormatInt(value, 10)
}
+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),
)
}

Some files were not shown because too many files have changed in this diff Show More