Compare commits

..
Author SHA1 Message Date
瑞达 e675d353bb docs(todo): refine multi-skill routing from train run 2 2026-08-21 14:50:51 +08:00
瑞达 78ed8f27b1 docs(todo): optimize multi-skill routing from train run 1 2026-08-21 14:50:51 +08:00
github-actions[bot] cd90d1c322 Merge pull request #1075 from Justper/oa_attachment_upload_dws
Oa attachment upload dws
2026-08-21 14:46:26 +08:00
昭逸 78433198fb Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 14:23:27 +08:00
昭逸 4863152a4a Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 14:20:33 +08:00
昭逸 2057fec3b0 fix(oa): normalize spaceId/fileSize to match ResultSpec integer declaration to #666
- validateOAAttachmentCommitResult 改为返回归一化后的 result map
- string 型 spaceId 通过 ParseInt 转 int64,json.Number 同理,非法字符串报错
- fileSize 的 json.Number 同样归一化为 int64
- 新增归一化行为测试 + 输出契约测试,覆盖率 100% to #666
2026-08-21 14:19:53 +08:00
github-actions[bot] 8b56e9bc9e Merge pull request #1073 from maoqxxmm/codex/sheet-revision-changeset
feat(sheet): add revision and changeset inspection
2026-08-21 05:56:10 +00:00
毛球 87e141f2de Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 13:38:56 +08:00
github-actions[bot] 9b521f0392 chore: update beta formula for v1.0.60-beta.1 [skip ci] 2026-08-21 05:17:50 +00:00
YanChangzhi 4dcd528bc1 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 13:11:34 +08:00
赤川 0bbb3a9d32 Merge pull request #1087 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.1
chore: prepare v1.0.60-beta.1 changelog
2026-08-21 12:48:22 +08:00
chichuan 0ebd840ba9 chore: prepare v1.0.60-beta.1 changelog 2026-08-21 12:35:38 +08:00
github-actions[bot] 9d356cd664 Merge pull request #1076 from hlzjsong/refresh_org_slot_fix
refresh org slot not only identity
2026-08-21 04:20:48 +00:00
YanChangzhi b00f43ee06 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 12:14:19 +08:00
赤川 23167ef974 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:46:45 +08:00
毛球 8b003aef16 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 11:43:45 +08:00
github-actions[bot] 11934eed05 Merge pull request #1081 from DingTalk-Real-AI/codex/fix-report-requiredness-governance
feat(policy): govern optional-to-required flag migrations
2026-08-21 11:40:11 +08:00
YanChangzhi 23e1085a37 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 11:16:29 +08:00
赤川 a352615e77 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:15:06 +08:00
xiatian d210da501a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 11:14:03 +08:00
赤川 6288199a93 Merge branch 'main' into codex/fix-report-requiredness-governance 2026-08-21 11:05:33 +08:00
赤川 5b34ed1a7e Merge pull request #1084 from DingTalk-Real-AI/codex/docs-dws-cli-open
docs: 公告 DWS CLI 全面开放
2026-08-21 11:02:36 +08:00
chichuan 3d9a469347 docs: announce DWS CLI availability 2026-08-21 11:01:08 +08:00
xiatian 7640ba7614 fix(sheet): validate changeset audit integrity 2026-08-21 10:59:14 +08:00
昭逸 4886bdb3f5 Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 10:41:06 +08:00
昭逸 4aef07ccd3 fix(oa): validate commit response required fields before reporting success to #666
- 新增 validateOAAttachmentCommitResult 校验 spaceId/fileName/fileSize/fileId 必需字段
- commit 步不再使用通用 callOAAttachmentResultCtx,改为专用校验后才存储成功结果
- 补充 malformed commit 响应回归测试,覆盖率 100%
2026-08-21 10:40:46 +08:00
github-actions[bot] e0c49377d6 Merge pull request #1074 from DingTalk-Real-AI/codex/investigate-calendar-todo-comment-regressions
fix: harden calendar todo and comment shortcuts
2026-08-21 10:30:04 +08:00
昭逸 dc37dd2c34 fix(oa): remove DDAttachment from unsupported table and use testseam.Swap to #666
- 从 oa-form-components.md (mono/multi) 的"API 不支持的控件"表中移除 DDAttachment,避免 Agent 误判为不支持
- computeFileMD5 测试注入改为 testseam.Swap,符合仓库包变量注入约定
2026-08-21 09:56:48 +08:00
hlzjsong 02aad9020a Merge branch 'main' into refresh_org_slot_fix 2026-08-21 09:26:39 +08:00
昭逸 d59d1091dc Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 08:51:13 +08:00
昭逸 60d6bfeec4 Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 08:50:28 +08:00
昭逸 bf89acb3d2 fix ci fail to #666 2026-08-21 08:50:13 +08:00
Dennis 35d6f47bd6 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-21 08:17:52 +08:00
xiatian d24b71614a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:23:17 +08:00
github-actions[bot] 765b961f4d Merge pull request #1071 from DingTalk-Real-AI/codex/fix-stable-active-fragments
fix(release): consume post-beta fragments in stable seals
2026-08-21 01:06:18 +08:00
xiatian 9ab4bd10a5 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:05:47 +08:00
chichuan 14c5bed4fc ci: split app-c race partition for runner headroom 2026-08-21 00:50:46 +08:00
赤川 c1bd6dcf64 Merge branch 'main' into codex/fix-stable-active-fragments 2026-08-21 00:06:27 +08:00
Dennis 1c8b83ec2f Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 23:58:13 +08:00
Dennis bb6f470df5 test: address shortcut regression review 2026-08-20 23:56:08 +08:00
赤川 01af71a5ae Merge branch 'main' into oa_attachment_upload_dws 2026-08-20 23:47:06 +08:00
github-actions[bot] d4ff8a5f4f Merge pull request #1070 from DingTalk-Real-AI/codex/oa-ding-report-shortcuts
feat(shortcut): harden OA DING and Report workflows
2026-08-20 23:44:23 +08:00
赤川 47e3c2b3c5 Merge branch 'main' into refresh_org_slot_fix 2026-08-20 23:19:33 +08:00
xiatian e8ecff586a fix(sheet): classify malformed revision responses 2026-08-20 23:06:56 +08:00
Dennis 11a9ad5d49 fix(shortcut): align OA execution availability 2026-08-20 23:03:35 +08:00
Dennis 23940e4752 revert: keep coverage shard output compact 2026-08-20 22:24:17 +08:00
Dennis 8cb0f64477 fix(shortcut): reject backward OA cursors 2026-08-20 22:15:27 +08:00
Dennis bbaf033618 ci: stream app coverage progress 2026-08-20 22:14:35 +08:00
xiatian 57cca7ef71 fix(sheet): validate revision result contracts 2026-08-20 22:02:26 +08:00
Dennis 2d38beb7be fix(shortcuts): separate compatibility visibility from availability 2026-08-20 21:51:21 +08:00
昭逸 4372a8c5ba Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-20 21:44:16 +08:00
昭逸 422dc0fde3 fix ci fail to #666 2026-08-20 21:44:05 +08:00
muling.cs 3d59411a1a refresh slot repair org 2026-08-20 21:24:06 +08:00
chichuan fe66ac18a4 feat(policy): govern flag requiredness changes 2026-08-20 21:19:55 +08:00
Dennis bda408d966 test(ding): cover compatibility reminder mappings 2026-08-20 21:10:45 +08:00
Dennis 33631502c9 fix(shortcuts): align unavailable writes and query validation 2026-08-20 21:02:52 +08:00
毛球 f2a608d146 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 20:44:17 +08:00
Dennis 378b9f67a2 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 20:41:56 +08:00
xiatian 43ba466783 fix(sheet): require fresh confirmation for version revert 2026-08-20 20:28:35 +08:00
Dennis 5c9f738012 fix(shortcuts): preserve published schema bindings 2026-08-20 20:20:41 +08:00
Dennis 56c5fb35b8 chore(policy): retire completed flag migrations 2026-08-20 20:20:27 +08:00
Dennis b19c52f61b fix(oa): make approval keyword normalization explicit 2026-08-20 20:20:25 +08:00
Dennis c0641dbf64 fix(shortcut): preserve OA and DING CLI compatibility 2026-08-20 20:20:23 +08:00
Dennis 3b5cb3b0cb test(shortcut): close OA DING Report coverage gaps 2026-08-20 20:20:21 +08:00
Dennis fd2ed2174f chore(release): add OA DING Report fragment 2026-08-20 20:20:19 +08:00
Dennis 5bbaa304e3 docs(shortcut): refresh OA DING Report live evidence 2026-08-20 20:20:17 +08:00
Dennis db938778af chore(shortcut): sync OA DING Report with current main 2026-08-20 20:20:15 +08:00
Dennis 1155b9b5c0 test(shortcut): align OA reviewed input fixtures 2026-08-20 20:20:12 +08:00
Dennis 8fa93ef030 fix(shortcut): retire OA discovery aliases after downgrade 2026-08-20 20:20:10 +08:00
Dennis f4ad1a15f5 docs(shortcut): record Report double-layer release proof 2026-08-20 20:20:08 +08:00
Dennis d2cbd928e2 docs(shortcut): record DING double-layer release proof 2026-08-20 20:20:06 +08:00
Dennis 2346dfba4e fix(shortcut): require OA zero-page pagination evidence 2026-08-20 20:20:03 +08:00
Dennis f6ad2fa01a fix(shortcut): publish Report range constraints 2026-08-20 20:19:44 +08:00
Dennis 7bc56f3dea feat(shortcut): unlock Report outbox workflows 2026-08-20 20:19:41 +08:00
Dennis 03001eb4e0 fix(shortcut): harden DING write routing evidence 2026-08-20 20:19:39 +08:00
Dennis 91974ce981 docs(shortcut): close OA residual audit gaps 2026-08-20 20:19:37 +08:00
Dennis c6417e3527 feat(shortcut): harden Report reads and availability 2026-08-20 20:19:35 +08:00
Dennis 161687ae4c fix(shortcut): deliver OA validation evidence 2026-08-20 20:19:32 +08:00
Dennis 4da5a07d1d feat(shortcut): harden DING reads and availability 2026-08-20 20:19:30 +08:00
Dennis 2cb83d388b fix(shortcut): publish OA validation constraints 2026-08-20 20:19:25 +08:00
Dennis ba9c0f624e feat(shortcut): harden OA workflows and availability 2026-08-20 20:19:19 +08:00
Dennis ff65f80c98 refactor(oa): add strict shortcut response helpers 2026-08-20 20:19:09 +08:00
github-actions[bot] 42240f5e9e Merge pull request #1079 from DingTalk-Real-AI/codex/fix-stable-migration-receipts
fix(ci): keep completed migration receipts inert
2026-08-20 20:18:04 +08:00
Dennis e6b06b561d Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 19:50:43 +08:00
chichuan 11cbc30a10 fix(ci): keep completed migration receipts inert 2026-08-20 19:16:07 +08:00
xiatian 60a474f30c fix(sheet): fail closed on invalid revision results 2026-08-20 19:15:48 +08:00
xiatian 6e8fec5684 chore(policy): retire consumed flag migrations 2026-08-20 17:48:59 +08:00
毛球 e028d443d5 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 17:11:29 +08:00
chichuan 74859b966b fix(release): consume active fragments in stable seals 2026-08-20 17:07:53 +08:00
xiatian 76a5559ca2 fix(sheet): align revision dry-run contract 2026-08-20 16:59:18 +08:00
昭逸 c4a1018213 删除无关文件 to #666 2026-08-20 16:55:18 +08:00
github-actions[bot] 62d72ad84c chore: update formula for v1.0.59 [skip ci] 2026-08-20 08:45:55 +00:00
Dennis e6fe475aea chore: retire consumed chat flag migration 2026-08-20 16:44:07 +08:00
muling.cs e95ac52ff4 refresh org slot not only identity 2026-08-20 16:41:35 +08:00
Dennis 61f8140f91 test: close shortcut regression coverage gaps 2026-08-20 16:38:05 +08:00
昭逸 09c0cd7849 merge uptream to #666 2026-08-20 16:26:32 +08:00
xiatian 5a368a9ab8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 16:11:37 +08:00
Dennis fbcd8887ee fix: harden calendar todo and comment shortcuts 2026-08-20 16:09:18 +08:00
赤川 c0838e7e41 Merge pull request #1072 from DingTalk-Real-AI/codex/changelog-v1.0.59-stable
chore: prepare v1.0.59 changelog
2026-08-20 16:06:54 +08:00
chichuan 9c6ab99bf1 chore: prepare v1.0.59 changelog 2026-08-20 16:01:09 +08:00
github-actions[bot] 87ab311764 chore: update beta formula for v1.0.59-beta.5 [skip ci] 2026-08-20 07:55:40 +00:00
昭逸 7f4318a10d fix审批skill中附件描述 to #666 2026-08-20 15:39:14 +08:00
xiatian 5232f632c8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 15:35:19 +08:00
xiatian 615a775fdf feat(sheet): add revision changeset inspection 2026-08-20 15:34:45 +08:00
昭逸 cefc5c005c 附件上传dws合并为一个 to #666 2026-08-20 15:14:39 +08:00
赤川 15c075e6a6 Merge pull request #1068 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.5
chore: prepare v1.0.59-beta.5 changelog
2026-08-20 14:56:06 +08:00
chichuan f6d1e685e0 chore: prepare v1.0.59-beta.5 changelog 2026-08-20 14:52:30 +08:00
github-actions[bot] 81108e150b Merge pull request #1046 from xlb1130/feat/85614588-chat-personal-emotion
feat(chat): add personal emotion commands
2026-08-20 06:27:50 +00:00
xlb1130 dad9aefefa Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 14:08:05 +08:00
github-actions[bot] 71d49cb12b Merge pull request #1033 from pengzhihan47-star/codex/dingtalk-doc-skill-opt-v1
docs(skill): optimize dingtalk-doc workflows
2026-08-20 14:04:45 +08:00
柏智 f7e2efaaa2 ci: retrigger checks 2026-08-20 13:50:18 +08:00
pengzhihan47-star 557208e16b Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:31:31 +08:00
xlb1130 53401dbb0c Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:25:41 +08:00
github-actions[bot] 6c52ac37dd Merge pull request #1066 from DingTalk-Real-AI/codex/minutes-todo-wiki-param-aliases
feat(cli): expand Minutes TODO Wiki parameter aliases
2026-08-20 13:21:38 +08:00
xlb1130 95a17a3ffc Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:21:07 +08:00
pengzhihan47-star 3318741508 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:01:04 +08:00
克谨 3d7ab2690c feat(cli): expand Minutes TODO Wiki parameter aliases 2026-08-20 12:45:19 +08:00
github-actions[bot] 17eefcd24b Merge pull request #1064 from DingTalk-Real-AI/codex/fix-1060-schema-lineage
fix(policy): preserve historical Schema migration lineage
2026-08-20 04:29:42 +00:00
xlb1130 6f62ce7997 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 12:17:24 +08:00
赤川 2228a32d1a Merge branch 'main' into codex/fix-1060-schema-lineage 2026-08-20 12:11:55 +08:00
github-actions[bot] a6f79e951b Merge pull request #1050 from DingTalk-Real-AI/codex/fix-cli-eval-functional
fix: harden shortcut functional workflows
2026-08-20 04:08:39 +00:00
长真 096dfd48f0 docs(chat): keep emotion skill route within budget 2026-08-20 11:57:55 +08:00
chichuan 3922970bfc fix(policy): preserve schema migration lineage 2026-08-20 11:52:00 +08:00
xlb1130 2ab0edd5c6 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:45:41 +08:00
pengzhihan47-star 4b3272bcd4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:42:41 +08:00
长真 80d5d24637 Revert "docs(chat): trim chat skill context budget"
This reverts commit c5951a10ff.
2026-08-20 11:39:50 +08:00
柏智 e40397e239 docs(skill): restore bounded doc guidance 2026-08-20 11:34:14 +08:00
xlb1130 15bc7fdc3f Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:27:40 +08:00
柏智 da049be58d docs(skill): require terminal evidence for doc writes 2026-08-20 11:21:58 +08:00
柏智 6ec64e8a03 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:24 +08:00
柏智 bca56cbba6 Merge remote-tracking branch 'origin/codex/dingtalk-doc-skill-opt-v1' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:19 +08:00
昭逸 103b05413e 审批附件相关dws help补充 to #666 2026-08-20 10:40:35 +08:00
john bb48aa0cc8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:44 +08:00
柏智 6ffb4bcb93 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:30 +08:00
昭逸 169bbe88c0 上传附件dws to #666 2026-08-20 10:24:35 +08:00
pengzhihan47-star 08595594d7 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:15:26 +08:00
柏智 540bbac35b Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 09:57:23 +08:00
柏智 c4d5595ca9 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 02:09:49 +08:00
柏智 86d1eb8030 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:47:50 +08:00
柏智 ab529e5ee5 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:05:02 +08:00
xlb1130 15cb1f4311 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 22:01:44 +08:00
长真 97ca00868f test(chat): cover personal emotion user resolution 2026-08-19 21:51:49 +08:00
xlb1130 df8885c350 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 21:06:35 +08:00
柏智 4e27a3a84a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 19:45:13 +08:00
xlb1130 8685464c53 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 19:28:52 +08:00
长真 26b5939f9f chore(ci): retrigger pr checks 2026-08-19 18:34:38 +08:00
柏智 95bcace6bd Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 18:10:22 +08:00
长真 ce57cdf260 chore(ci): retrigger pr checks 2026-08-19 16:26:32 +08:00
xlb1130 3ec138ba99 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 15:14:09 +08:00
柏智 f419c0f96d Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 14:37:01 +08:00
xlb1130 17101a8901 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 13:41:20 +08:00
柏智 66aa00fb50 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 12:23:42 +08:00
长真 9d6e151a6f Merge remote-tracking branch 'upstream/main' into feat/85614588-chat-personal-emotion 2026-08-19 10:54:30 +08:00
柏智 0df41d3eff Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:40:45 +08:00
柏智 2a1ed8cc7a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:09:48 +08:00
长真 c5951a10ff docs(chat): trim chat skill context budget 2026-08-19 00:55:27 +08:00
长真 3dbd29ab50 feat(chat): add personal emotion commands 2026-08-18 23:59:15 +08:00
柏智 1b50c7a5b4 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 23:25:57 +08:00
柏智 cbd70d1b88 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 21:03:52 +08:00
柏智 0ae8949d40 fix(doc): align skill contracts with runtime 2026-08-18 20:53:02 +08:00
pengzhihan47-star 0975d970d1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 20:36:25 +08:00
柏智 7808673431 fix(doc): align media receipt contract 2026-08-18 20:31:07 +08:00
pengzhihan47-star 50ed921ca1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 19:28:45 +08:00
pengzhihan47-star b34c29ec35 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 18:41:09 +08:00
pengzhihan47-star 97e5ded043 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 13:49:34 +08:00
pengzhihan47-star 7ceeafbae8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 12:27:08 +08:00
pengzhihan47-star 54b4a24a14 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 11:35:30 +08:00
pengzhihan47-star e1bfb343f4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 10:13:27 +08:00
柏智 7da423bf3c docs(skill): clarify import and recent document routes 2026-08-18 09:37:47 +08:00
柏智 fa83ee579c docs(skill): remove lark-specific wording 2026-08-18 08:25:06 +08:00
pengzhihan47-star 25c694aa2a Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 07:46:06 +08:00
柏智 e064d394ba docs(skill): optimize dingtalk doc workflows 2026-08-18 01:02:37 +08:00
170 changed files with 14966 additions and 4216 deletions
+3 -1
View File
@@ -25,7 +25,9 @@ category: Added
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
@@ -0,0 +1,5 @@
---
category: Added
---
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
@@ -0,0 +1,7 @@
---
category: Fixed
---
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
@@ -0,0 +1,11 @@
---
category: Fixed
---
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
business code, `dws` now retries once with the still-valid token mirrored in the same
organization's slot (same corp, matching or backfilled user identity) before giving up,
and writes the rotated credential back to both the identity and the organization slots so
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
rejections without a reviewed business code do not trigger the fallback.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
+12 -4
View File
@@ -534,7 +534,8 @@ jobs:
# where the Schema partition alone owned most of the wall clock. The
# app-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently.
# its job silently. The CrossPlatformCoverage-heavy C range is split again
# to retain headroom on runners reclaimed near the five-minute mark.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -542,7 +543,10 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
@@ -678,7 +682,8 @@ jobs:
# partitions run concurrently and each releases its framework registries
# when the process exits; cli/smoke need headroom beyond go test -timeout for
# setup + assembly. The app-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -686,7 +691,10 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
+4 -2
View File
@@ -2698,9 +2698,11 @@ jobs:
;;
compatibility)
test -n "$PREVIOUS_STABLE"
./scripts/policy/check-command-compatibility.sh \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
--repo-root "$GITHUB_WORKSPACE" \
--base-ref HEAD \
--stable-ref "$PREVIOUS_STABLE"
--stable-ref "$PREVIOUS_STABLE" \
--candidate-ref HEAD
;;
e2e)
bash scripts/dev/test-multi-profile-e2e.sh
+48
View File
@@ -6,6 +6,54 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.60-beta.1] - 2026-08-21
### Changed
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
### Fixed
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
business code, `dws` now retries once with the still-valid token mirrored in the same
organization's slot (same corp, matching or backfilled user identity) before giving up,
and writes the rotated credential back to both the identity and the organization slots so
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
rejections without a reviewed business code do not trigger the fallback.
## [1.0.59] - 2026-08-20
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
### Changed
- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions.
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage.
- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
## [1.0.59-beta.5] - 2026-08-20
### Added
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
### Fixed
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
## [1.0.59-beta.4] - 2026-08-20
### Added
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.59-beta.4"
version "1.0.60-beta.1"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz"
sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-arm64.tar.gz"
sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz"
sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip"
sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58"
version "1.0.59"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
end
def install
+1 -1
View File
@@ -19,7 +19,7 @@
</p>
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
+55 -8
View File
@@ -1,6 +1,11 @@
# CLI Help / Schema 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
本文定义两种受控 flag 迁移:
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
@@ -44,7 +49,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
scripts/policy/interface-migrations/approved-command-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
## 跨命令迁移原语
@@ -68,6 +75,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
`state=consumed` 的 flag rename 回执作为前驱边。例如
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
@@ -86,22 +104,22 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
@@ -146,6 +164,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
```json
{
"version": 1,
"migrations": [
{
"kind": "requiredness_change",
"command": "dws report entry submit",
"flag": {
"name": "to-user-ids",
"before": {"present": true, "type": "string", "scope": "local"},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reject report submissions that have no visible recipient."
}
]
}
```
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
@@ -166,10 +205,11 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -177,6 +217,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
@@ -204,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
任何不相干漂移继续阻塞。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
+6 -3
View File
@@ -3,7 +3,7 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Products**: 13
- **Total commands**: 160
- **Total commands**: 163
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
_Group chats, conversations, messages, and robot/webhook integrations._
**23 commands**
**26 commands**
| Command | Description | When to use |
|---|---|---|
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
+10 -5
View File
@@ -23,7 +23,7 @@
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
@@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与
归档移动,供审计复核。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
+42 -73
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-19T10:35:58.304269",
"count": 423,
"generated_at": "2026-08-20T11:51:44.156046",
"count": 416,
"results": [
{
"suite": "semantic",
@@ -2494,32 +2494,14 @@
"status": "real-ok"
},
{
"suite": "read",
"service": "ding",
"command": "+list",
"risk": "read",
"status": "real-ok"
},
{
"suite": "write",
"service": "ding",
"command": "+recall-personal",
"risk": "high-risk-write",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "ding",
"command": "+receiver-status",
"risk": "read",
"status": "real-ok"
},
{
"suite": "write",
"service": "ding",
"command": "+send-personal",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
"availability": "available"
},
{
"suite": "semantic",
@@ -3602,67 +3584,54 @@
"availability": "available"
},
{
"suite": "read",
"service": "oa",
"command": "+list-cc",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-executed",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-forms",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-pending",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+list-submitted",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"service": "oa",
"command": "+my-initiated",
"risk": "read",
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "oa",
"command": "+search-forms",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "report",
"command": "+inbox-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "report",
"command": "+outbox-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
"availability": "available"
},
{
"suite": "semantic",
"service": "report",
"command": "+report-latest",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
"availability": "available"
},
{
"suite": "semantic",
"service": "report",
"command": "+template-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
"availability": "available"
},
{
"suite": "read",
@@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
{path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
for _, canonical := range []string{
"oa.shortcut_approve_by",
"oa.shortcut_done_approvals",
"oa.shortcut_list_cc",
"oa.shortcut_list_executed",
"oa.shortcut_list_forms",
"oa.shortcut_list_pending",
"oa.shortcut_list_submitted",
"oa.shortcut_my_initiated",
"oa.shortcut_pending",
"oa.shortcut_search_forms",
} {
tool, ok := snapshot.Tools[canonical]
if !ok {
t.Errorf("final Schema lacks OA tool %s", canonical)
continue
}
if got := tool["availability"]; got != contract.InterfaceAvailable {
t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable)
}
}
}
+28
View File
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"success":true,"result":[]}`
case "list_suggested_event_times":
return `{"success":true,"result":{"recommendEventTimes":[]}}`
case "list_by_keyword_and_time_range":
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
case "get_minutes_basic_info":
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
case "get_minutes_transcription":
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
case "create_personal_todo":
return `{"success":true,"result":{"taskId":"task-1"}}`
case "get_todo_detail":
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
case "get_user_todos_in_current_org":
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
case "add_todo_reminder":
return `{"success":true}`
case "copy_document":
return `{"success":true,"nodeId":"copy-1"}`
case "move_document", "add_member", "update_member", "remove_member":
return `{"success":true}`
case "get_document_info":
if len(c.calls) > 1 {
switch c.calls[len(c.calls)-2].tool {
case "copy_document":
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
case "move_document":
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
}
}
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
case "create_calendar_event":
return `{"success":true,"result":{"eventId":"event-1"}}`
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
@@ -226,12 +226,72 @@ var paramAliasCompleteCommands = map[string][]string{
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// paramAliasCandidateCompleteCommands contains complete invocations for the
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
// separate map lets this test file land before the draft replaces the formal
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
}
// A command can expose more than one mutually exclusive canonical route. In
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
@@ -531,6 +591,20 @@ var paramAliasNewConfirmationCases = []struct {
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
}
// Candidate confirmation cases become active with the joint draft. One write
// workflow per product plus TODO's reminder workflow proves semantic aliasing
// cannot move execution across the shared --yes barrier.
var paramAliasCandidateConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
{command: "todo +create", emitted: "deadline", canonical: "due"},
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
@@ -600,6 +674,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
// Candidate representatives exercise the final transport boundary for each
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
// exists in the loaded reviewed table, so the tests are mergeable before the
// joint draft is promoted to internal/cli/param_concepts.json.
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
paramAliasPayloadCaseKey("todo comment add", "text"): true,
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
paramAliasPayloadCaseKey("todo task update", "status"): true,
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
}
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
// separate from the long-lived app-c race process. Each case still executes
// both canonical and alias argv through the real PreParse/Cobra path and
@@ -709,6 +807,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands := make(map[string]bool)
activeFixtureCases := make(map[string]bool)
activeCases := 0
executedRepresentatives := make(map[string]bool)
for _, fixture := range concepts.Fixture {
@@ -717,6 +816,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands[fixture.Command] = true
activeCases++
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
activeFixtureCases[caseKey] = true
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
@@ -729,8 +830,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
continue
}
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasRepresentativePayloadCases[caseKey] {
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
continue
}
executedRepresentatives[caseKey] = true
@@ -742,26 +842,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if activeCases == 0 {
t.Fatal("reviewed fixture contains no active alias cases")
}
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
for command := range paramAliasCompleteCommands {
if !activeCommands[command] {
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
}
templateCommands[command] = true
}
for command := range paramAliasCandidateCompleteCommands {
if activeCommands[command] {
templateCommands[command] = true
}
}
for command := range activeCommands {
if _, ok := paramAliasCompleteCommands[command]; !ok {
if !templateCommands[command] {
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
}
}
if len(activeCommands) != len(paramAliasCompleteCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
if len(activeCommands) != len(templateCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
}
for caseKey := range paramAliasRepresentativePayloadCases {
if !executedRepresentatives[caseKey] {
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
if !activeFixtureCases[caseKey] {
continue
}
activeRepresentatives++
if !executedRepresentatives[caseKey] {
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
}
}
if len(executedRepresentatives) != activeRepresentatives {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
}
}
@@ -1091,7 +1208,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
}
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewConfirmationCases {
tests := append([]struct {
command string
emitted string
canonical string
}{}, paramAliasNewConfirmationCases...)
for _, candidate := range paramAliasCandidateConfirmationCases {
entry, exists := cli.LookupParamAlias(candidate.command)
target, active := entry.ResolveAlias(candidate.emitted)
if exists && active && target == candidate.canonical {
tests = append(tests, candidate)
}
}
for _, test := range tests {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
@@ -1173,6 +1303,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
return variant, true
}
}
if ok {
return complete, true
}
complete, ok = paramAliasCandidateCompleteCommands[command]
return complete, ok
}
+3
View File
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat emotion favorite",
"chat emotion list",
"chat emotion send",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
@@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
tool := snapshot.Tools["oa.attachment_upload"]
if tool == nil {
t.Fatal("oa.attachment_upload is missing from final Schema")
}
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Fatalf("interface_mode = %q, want composite", got)
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Fatalf("availability = %q, want available", got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Fatal("composite upload command must document an interface reason")
}
if got := schemaContractString(tool["effect"]); got != "write" {
t.Fatalf("effect = %q, want write", got)
}
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)
}
parameters := schemaContractMap(tool["parameters"])
for _, flag := range []string{"file", "file-name", "md5"} {
if parameters[flag] == nil {
t.Fatalf("upload --%s is missing from final Schema", flag)
}
}
if required, _ := parameters["file"]["required"].(bool); !required {
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
}
result := schemaContractMap(tool["result"])
dataSchema := schemaContractMap(result["data_schema"])
properties := schemaContractMap(dataSchema["properties"])
if properties["fileId"] == nil {
t.Fatal("upload Result data_schema is missing fileId")
}
}
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)
+56 -5
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 422
publicShortcutCount = 415
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 447
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 453
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 422
publiclyDeliveredShortcutCount = 415
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 217; got != want {
if got, want := int(product["count"].(float64)), 220; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
assertChatCatalogCompleteLeafContracts(t)
}
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
for _, tc := range []struct {
cliPath string
params map[string]string
}{
{
cliPath: "chat emotion list",
},
{
cliPath: "chat emotion send",
params: map[string]string{
"media-id": "mediaId",
"emotion-id": "emotionId",
"group": "openConversationId",
"open-dingtalk-id": "receiverOpenDingTalkId",
"idempotency-key": "uuid",
},
},
{
cliPath: "chat emotion favorite",
params: map[string]string{
"media-id": "mediaId",
"name": "name",
"source-conversation-id": "sourceConversationId",
"source-message-id": "sourceMessageId",
},
},
} {
t.Run(tc.cliPath, func(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
}
reason := schemaContractString(leaf["interface_reason"])
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
}
parameters := schemaContractMap(leaf["parameters"])
for name, want := range tc.params {
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
}
if got := schemaContractString(parameter["property"]); got != want {
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
}
}
})
}
}
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
result, _ := leaf["result"].(map[string]any)
+2
View File
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
+78 -1
View File
@@ -811,7 +811,84 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("refreshing token (dual-locked)")
}
return oauthRefreshToken(p, ctx, data)
refreshed, rErr := oauthRefreshToken(p, ctx, data)
if rErr == nil || !isRefreshTokenRejected(rErr) {
return refreshed, rErr
}
// A stale identity slot can survive an older organization-only refresh.
// Retry once with the same-corp organization mirror while holding the
// existing dual lock; the fallback marks the publication so the rotated
// credential is written back into the mirror slot it consumed.
logging.AuthDebug(
"auth.refresh.fallback.triggered",
"corp_id", strings.TrimSpace(data.CorpID),
"user_id", strings.TrimSpace(data.UserID),
"error", rErr,
)
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
if fErr != nil {
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
return nil, rErr
}
if p.logger != nil {
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
}
return fallback, nil
}
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
// the organization slot. The mirror must match the current corp, be valid,
// and differ from the rejected token. When both slots carry user identities,
// they must agree; legacy mirrors with an empty UserID are backfilled from the
// current identity before refresh.
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
if current == nil {
return nil, fmt.Errorf("no current token data")
}
corpID := strings.TrimSpace(current.CorpID)
if corpID == "" {
return nil, fmt.Errorf("current token has no corpId")
}
orgData, err := tokenLoadKeychainForCorpID(corpID)
if err != nil {
return nil, err
}
if orgData == nil {
return nil, ErrTokenDataNotFound
}
if strings.TrimSpace(orgData.CorpID) != corpID {
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
}
if !orgData.IsRefreshTokenValid() {
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
}
if orgData.RefreshToken == current.RefreshToken {
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
}
currentUserID := strings.TrimSpace(current.UserID)
orgUserID := strings.TrimSpace(orgData.UserID)
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
}
if orgUserID == "" {
orgData.UserID = current.UserID
orgData.UserName = current.UserName
}
// The refresh below consumes the mirror's refresh_token. Mark the
// publication so persistence writes the rotated credential back into the
// organization slot even under an explicit runtime selector whose plan
// would otherwise skip it (for example a preserved unresolved sibling).
orgData.RepairOrganizationMirror = true
refreshed, err := oauthRefreshToken(p, ctx, orgData)
if err != nil {
return nil, err
}
logging.AuthDebug(
"auth.refresh.fallback.success",
"corp_id", corpID,
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
)
return refreshed, nil
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
@@ -0,0 +1,427 @@
// 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 auth
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
{"plain error is unknown", errors.New("boom"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRefreshTokenRejected(tt.err); got != tt.want {
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
// and records refresh attempts plus organization slot lookups.
type orgSlotFallbackFixture struct {
provider *OAuthProvider
stale *TokenData
orgMirror *TokenData
renewed *TokenData
rejected *MCPTokenExchangeError
refreshErr error
orgRefreshErr error
refreshCalls []string
refreshUserIDs []string
orgLoads int
}
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
t.Helper()
isolateOAuthPersistence(t)
f := &orgSlotFallbackFixture{
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
stale: &TokenData{
AccessToken: "old-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
orgMirror: &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
renewed: &TokenData{
AccessToken: "new-access",
RefreshToken: "new-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
}
f.refreshErr = f.rejected
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
switch data.RefreshToken {
case "stale-refresh":
return nil, f.refreshErr
case "org-refresh":
return f.renewed, f.orgRefreshErr
}
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
f.orgLoads++
if corpID != "corp-1" {
return nil, ErrTokenDataNotFound
}
return f.orgMirror, nil
})
return f
}
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
}
if f.orgLoads != 1 {
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
}
}
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "legacy", CorpID: "corp-1", UserID: ""},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
},
}
selector := profileSelector("corp-1", "user-1")
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
}
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
t.Fatalf("repair marker did not force the organization slot write")
}
}
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
isolateOAuthPersistence(t)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
// Fake MCP refresh endpoint: the first call rejects the stale identity
// refresh_token with the reviewed business code; the second call (the
// organization mirror) succeeds and returns a rotated credential.
var refreshCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if refreshCalls.Add(1) == 1 {
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
return
}
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
}))
defer srv.Close()
configDir := setupMCPConfigDir(t, srv.URL)
resetAppConfigCache()
// Seed the pre-fallback state: an identity slot whose refresh_token the
// server rejects, plus a legacy organization mirror (no userId) with a
// still-valid refresh_token and a preserved unresolved sibling profile so
// an explicit --profile refresh would normally skip the org slot.
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
},
}
if err := SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
orgMirror := &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
}
staleIdentity := &TokenData{
AccessToken: "stale-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
UserName: "User One",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
}
SetRuntimeProfile("corp-1:user-1")
t.Cleanup(func() { SetRuntimeProfile("") })
p := &OAuthProvider{
configDir: configDir,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Output: io.Discard,
httpClient: srv.Client(),
}
got, err := p.lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
}
if refreshCalls.Load() != 2 {
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
}
// The fallback consumed the mirror's refresh_token: both persisted slots
// must now carry the rotated credential instead of the consumed one.
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
}
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
}
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
}
if identitySlot.RefreshToken != "new-refresh" {
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
}
}
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
t.Run("transient failure does not fall back", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing org slot preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, ErrTokenDataNotFound
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, nil
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
}
})
t.Run("different user in org slot is rejected", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = "user-2"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
}
})
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = ""
f.orgMirror.UserName = ""
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
}
if f.refreshUserIDs[1] != "user-1" {
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
}
})
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshToken = "stale-refresh"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
}
})
t.Run("expired org refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing corp id skips fallback", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.stale.CorpID = ""
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
}
})
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
}
if f.orgLoads != 0 {
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
}
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
t.Fatal("refreshFromOrgSlot(nil) succeeded")
}
f.orgMirror.CorpID = "corp-2"
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
}
if len(f.refreshCalls) != 0 {
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
}
}
+13
View File
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
}
return RefreshFailureUnknown
}
// isRefreshTokenRejected reports whether the server returned a reviewed
// business code that definitively rejects the presented refresh_token.
// Only the reviewed MCP business code enables the organization-slot
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
// reviewed business code and deliberately do not trigger the fallback.
func isRefreshTokenRejected(err error) bool {
if err == nil {
return false
}
var exchangeErr *MCPTokenExchangeError
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
}
+16 -1
View File
@@ -107,6 +107,13 @@ type TokenData struct {
// transient marker to reject ambiguous UID-less logins without breaking
// legitimate refreshes of unresolved accounts.
FreshAuthorization bool `json:"-"`
// RepairOrganizationMirror marks a fallback refresh that consumed the
// organization mirror's refresh_token. The regular write plan can skip the
// organization slot under an explicit runtime selector (for example when an
// unresolved sibling profile still owns it), which would strand a
// refresh_token the server has already rotated; the marker forces the
// rotated credential back into that slot.
RepairOrganizationMirror bool `json:"-"`
}
// tokenPersistenceWritePlan is the single source of truth for deciding which
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
ExistingIdentity bool
UpgradesLegacyProfile bool
PreserveUnresolvedOrganization bool
RepairOrganizationMirror bool
WriteIdentity bool
WriteOrganization bool
WriteGlobal bool
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
!plan.UpgradesLegacyProfile
// A fallback refresh consumed the organization mirror's refresh_token;
// the rotated credential must go back into that slot even when the
// selector-driven plan would skip it (for example an explicit --profile
// that preserves an unresolved sibling profile's slot).
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
plan.WriteIdentity = plan.UserID != ""
orgCurrentSelector := ""
if cfg != nil {
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
// reauthorization. Its organization slot must move with the newly exact
// identity even when an explicit runtime selector keeps it from becoming
// process-global current.
plan.WriteOrganization = plan.UserID == "" ||
plan.WriteOrganization = plan.RepairOrganizationMirror ||
plan.UserID == "" ||
plan.UpgradesLegacyProfile ||
(!plan.PreserveUnresolvedOrganization &&
(plan.MakeCurrent ||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
"persistence_profile", plan.PersistenceSelector,
"write_identity_slot", plan.WriteIdentity,
"write_org_mirror", plan.WriteOrganization,
"repair_org_mirror", plan.RepairOrganizationMirror,
"write_global_mirror", plan.WriteGlobal,
"publish_incoming_global", plan.MakeCurrent,
)
+3 -3
View File
@@ -191,12 +191,12 @@
"from": "oa +list-processes",
"mode": "ambiguous",
"candidates": [
"oa +list-forms",
"oa +my-initiated",
"oa +search-forms",
"oa approval list-submitted",
"oa approval list-initiated"
],
"reviewed": true,
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
"review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes."
},
{
"from": "chat +conversation-detail",
@@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{
{
From: "oa +list-processes",
Mode: "ambiguous",
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
Reviewed: true,
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.",
},
}
+1 -1
View File
@@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
"chat +send-file": {"chat +messages-send", "chat message send"},
"chat +send-image": {"chat +messages-send", "chat message send"},
"chat +send-media": {"chat +messages-send", "chat message send"},
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
"oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error {
if params == nil {
params = map[string]any{}
}
if err := validateSchemaValue("$", params, schema); err != nil {
if err := ValidateJSONSchemaValue(params, schema); err != nil {
return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err))
}
return nil
}
// ValidateJSONSchemaValue validates one decoded JSON value against the
// required/type/enum/properties/items subset used by reviewed CLI contracts.
func ValidateJSONSchemaValue(value any, schema map[string]any) error {
return validateSchemaValue("$", value, schema)
}
func validateSchemaValue(path string, value any, schema map[string]any) error {
if len(schema) == 0 {
return nil
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package commentreaction validates the reviewed DingTalk names accepted by
// comment emoji replies. The names mirror the bundled default emoji catalog;
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
package commentreaction
import (
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// Validate rejects values outside DingTalk's reviewed default reaction-name
// catalog. Names are case-sensitive because they are sent verbatim to the
// doc-comment service.
func Validate(value string) error {
name := strings.TrimSpace(value)
if value != name || !utf8.ValidString(name) || !supported(name) {
return apperrors.NewValidation(
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
apperrors.WithReason("unsupported_comment_reaction"),
)
}
return nil
}
func supported(name string) bool {
switch name {
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
return true
default:
return false
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package commentreaction
import "testing"
func TestCrossPlatformCoverageValidate(t *testing.T) {
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
if err := Validate(value); err != nil {
t.Errorf("supported reaction %q rejected: %v", value, err)
}
}
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
if err := Validate(value); err == nil {
t.Errorf("unsupported reaction %q accepted", value)
}
}
}
+1 -1
View File
@@ -10569,7 +10569,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand())
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand(), newChatEmotionCommand())
// Keep the v1.0.56 command surface recognizable while directing callers to
// the supported nested commands. The chat root's "im" alias makes these
+253
View File
@@ -0,0 +1,253 @@
package helpers
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
func newChatEmotionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "emotion",
Short: "个人收藏表情",
Long: "查询、发送和新增当前用户的个人收藏表情。",
RunE: groupRunE,
}
cmd.AddCommand(
newChatEmotionListCommand(),
newChatEmotionSendCommand(),
newChatEmotionFavoriteCommand(),
)
return cmd
}
func newChatEmotionListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "列出个人收藏表情",
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "list_personal_emotions",
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
},
Description: "列出当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "列出当前用户的个人收藏表情",
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
Examples: []string{"dws chat emotion list --format json"},
},
},
})
return cmd
}
func newChatEmotionSendCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "send",
Short: "发送个人收藏表情",
Long: `发送当前用户的个人收藏表情。
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
target, err := personalEmotionSendTarget(cmd)
if err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
emotionID, _ := cmd.Flags().GetString("emotion-id")
if strings.TrimSpace(emotionID) != "" {
payload["emotionId"] = strings.TrimSpace(emotionID)
}
for key, value := range target {
payload[key] = value
}
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
payload["uuid"] = uuid
}
return callMCPToolOnServer("im", "send_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
cmd.Flags().String("emotion-id", "", "表情 ID")
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
cmd.Flags().String("uuid", "", "幂等键")
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
})
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionSendContract(),
})
return cmd
}
func newChatEmotionFavoriteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "favorite",
Short: "新增个人收藏表情",
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
name, _ := cmd.Flags().GetString("name")
if strings.TrimSpace(name) != "" {
payload["name"] = strings.TrimSpace(name)
}
if strings.TrimSpace(sourceConversationID) != "" {
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
}
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
cmd.Flags().String("name", "", "表情名称")
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionFavoriteContract(),
})
return cmd
}
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
userID, _ := cmd.Flags().GetString("user")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
userID = strings.TrimSpace(userID)
openDingTalkID = strings.TrimSpace(openDingTalkID)
specified := 0
for _, value := range []string{groupID, userID, openDingTalkID} {
if value != "" {
specified++
}
}
if specified != 1 {
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
}
if groupID != "" {
return map[string]any{"openConversationId": groupID}, nil
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return nil, err
}
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
}
if isOpenDingTalkID(userID) {
return map[string]any{"receiverOpenDingTalkId": userID}, nil
}
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
if err != nil {
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
}
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
}
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
hasConversation := strings.TrimSpace(sourceConversationID) != ""
hasMessage := strings.TrimSpace(sourceMessageID) != ""
if hasConversation != hasMessage {
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
}
return nil
}
func personalEmotionSendContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "send_personal_emotion",
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
},
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "以当前用户身份发送个人收藏表情",
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
},
}
}
func personalEmotionFavoriteContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "favorite_personal_emotion",
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
},
Description: "将 mediaId 新增到当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "新增当前用户的个人收藏表情",
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "name", Property: "name", Required: boolPtr(false)},
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
},
}
}
@@ -0,0 +1,254 @@
package helpers
import (
"context"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type personalEmotionCall struct {
server string
tool string
args map[string]any
}
type personalEmotionCaller struct {
calls []personalEmotionCall
}
func (c *personalEmotionCaller) 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, personalEmotionCall{server: server, tool: tool, args: copied})
if server == "contact" && tool == "get_user_info_by_user_ids" {
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
}
return textToolResult(`{"ok":true}`), nil
}
func (*personalEmotionCaller) Format() string { return "json" }
func (*personalEmotionCaller) DryRun() bool { return false }
func (*personalEmotionCaller) Fields() string { return "" }
func (*personalEmotionCaller) JQ() string { return "" }
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
t.Helper()
installHelpersCoreDeps(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.ExecuteContext(context.Background())
}
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
t.Helper()
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.server != "im" || call.tool != tool {
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
}
if !reflect.DeepEqual(call.args, want) {
t.Fatalf("args = %#v, want %#v", call.args, want)
}
}
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
caller := &personalEmotionCaller{}
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
t.Fatalf("chat emotion list returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
}
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--emotion-id", "emotion123",
"--group", "cid123",
"--idempotency-key", "idem-001",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"emotionId": "emotion123",
"openConversationId": "cid123",
"uuid": "idem-001",
})
}
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--open-dingtalk-id", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", "u1",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
}
resolveCall := caller.calls[0]
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
}
sendCall := caller.calls[1]
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
}
want := map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID2,
}
if !reflect.DeepEqual(sendCall.args, want) {
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
}
}
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing target",
args: []string{"emotion", "send", "--media-id", "@media"},
wantErr: "specify exactly one",
},
{
name: "multiple targets",
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
wantErr: "specify exactly one",
},
{
name: "missing media",
args: []string{"emotion", "send", "--group", "cid"},
wantErr: "--media-id is required",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "favorite",
"--media-id", "@media",
"--name", "赞",
"--source-conversation-id", "cid123",
"--source-message-id", "msg123",
)
if err != nil {
t.Fatalf("chat emotion favorite returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
"mediaId": "@media",
"name": "赞",
"sourceConversationId": "cid123",
"sourceMessageId": "msg123",
})
}
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing media",
args: []string{"emotion", "favorite", "--name", "赞"},
wantErr: "--media-id is required",
},
{
name: "source conversation only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
wantErr: "must be specified together",
},
{
name: "source message only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
wantErr: "must be specified together",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
+4
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
return err
}
if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil {
return err
}
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
"nodeId": nodeID,
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
+23
View File
@@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
}
}
func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) {
for _, surface := range []string{"doc", "sheet"} {
for _, test := range []struct {
name string
args []string
}{
{name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}},
{name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}},
{name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}},
} {
t.Run(surface+"/"+test.name, func(t *testing.T) {
caller := &docCommentMutationCaller{}
if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil {
t.Fatal("unsupported reaction accepted")
}
if len(caller.calls) != 0 {
t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls)
}
})
}
}
}
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
for _, surface := range []struct {
name string
+20 -2
View File
@@ -17,6 +17,16 @@ import (
// remindType: 服务端 API 1=应用内 2=短信 3=电话
var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3}
var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"}
func dingPersonalRemindType(value string) (string, error) {
remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))]
if !ok {
return "", fmt.Errorf("--type must be one of app, sms, call")
}
return remindType, nil
}
func newDingCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/ding.json
// products.ding). Catalog assembly stamps provenance contract_final.
@@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "users", "content"); err != nil {
return err
}
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
if err != nil {
return err
}
users := parseCSVValues(mustGetFlag(cmd, "users"))
toolArgs := map[string]any{
"receiverOpenDingTalkIds": users,
"content": mustGetFlag(cmd, "content"),
"remindType": mustGetFlag(cmd, "type"),
"remindType": remindType,
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
@@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil {
return err
}
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
if err != nil {
return err
}
users := parseCSVValues(mustGetFlag(cmd, "users"))
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openMessageId": mustGetFlag(cmd, "message-id"),
"receiverOpenDingTalkIds": users,
"remindType": mustGetFlag(cmd, "type"),
"remindType": remindType,
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
@@ -0,0 +1,18 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package helpers
import "testing"
func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) {
for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} {
got, err := dingPersonalRemindType(input)
if err != nil || got != want {
t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want)
}
}
if got, err := dingPersonalRemindType("push"); err == nil || got != "" {
t.Fatalf("unsupported remind type=(%q,%v)", got, err)
}
}
+4
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"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"
@@ -3220,6 +3221,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
return err
}
groupMentions, err := commentGroupMentionIDs(cmd)
if err != nil {
return err
+3 -3
View File
@@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", "oc-1",
)
if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") {
@@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", " ",
)
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
@@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji",
)
if err != nil {
+288 -4
View File
@@ -2,10 +2,13 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -18,6 +21,9 @@ import (
"github.com/spf13/cobra"
)
// computeFileMD5 is the function used to calculate file MD5. Tests can override via direct assignment.
var computeFileMD5 = fileMD5Hex
func decodeOARequest(raw string) (map[string]any, error) {
dec := json.NewDecoder(bytes.NewBufferString(raw))
dec.UseNumber()
@@ -126,7 +132,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
}
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
return callOAAttachmentResultCtx(cmd.Context(), tool, args)
}
// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context
// (例如 upload 命令的 10 分钟超时):调用 oa/<tool>,提取 result 并包装为 output.Success。
func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args)
if err != nil {
return nil, err
}
@@ -141,10 +153,225 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any
return output.Success(result), nil
}
// validateOAAttachmentCommitResult 校验 commit_attachment_upload_info 的原始 result
// 包含构造 DDAttachment 所需的全部必需字段,并将 spaceId/fileSize 归一化为声明的 integer
// 类型(int64),确保输出始终符合 ResultSpec schema。当字段缺失或类型错误时返回 Validation
// 错误,避免 Agent 拿到空 fileId 后组装无效的审批表单。
func validateOAAttachmentCommitResult(result any) (map[string]any, error) {
resultMap, ok := result.(map[string]any)
if !ok || resultMap == nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象")
}
// spaceId — 接受 string 或 number,归一化为 int64
switch v := resultMap["spaceId"].(type) {
case string:
if strings.TrimSpace(v) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
}
resultMap["spaceId"] = parsed
case float64:
// numeric spaceId — keep as-is (already matches JSON number)
case json.Number:
parsed, err := v.Int64()
if err != nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
}
resultMap["spaceId"] = parsed
default:
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
// fileName — must be non-empty string
if s, ok := resultMap["fileName"].(string); !ok || strings.TrimSpace(s) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName")
}
// fileSize — must be > 0 (number),归一化 json.Number → int64
switch v := resultMap["fileSize"].(type) {
case float64:
if v <= 0 {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
case json.Number:
parsed, err := v.Int64()
if err != nil || parsed <= 0 {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
resultMap["fileSize"] = parsed
default:
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize")
}
// fileId — must be non-empty string
if s, ok := resultMap["fileId"].(string); !ok || strings.TrimSpace(s) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId")
}
return resultMap, nil
}
// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、
// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、
// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。
func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) {
var data map[string]any
if err = json.Unmarshal([]byte(text), &data); err != nil {
return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err))
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result")
}
uploadKey, _ = result["uploadKey"].(string)
if strings.TrimSpace(uploadKey) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey")
}
rawURLs, ok := result["resourceUrls"].([]any)
if !ok || len(rawURLs) == 0 {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls")
}
resourceURL, _ = rawURLs[0].(string)
if strings.TrimSpace(resourceURL) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空")
}
rawHeaders, ok := result["headers"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 headers")
}
headers = make(map[string]string)
for key, value := range rawHeaders {
if str, ok := value.(string); ok {
headers[key] = str
}
}
if strings.TrimSpace(headers["Authorization"]) == "" || strings.TrimSpace(headers["x-oss-date"]) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info headers 缺少必需的签名字段 Authorization 或 x-oss-date")
}
return resourceURL, headers, uploadKey, nil
}
// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节
// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并
// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。
func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
filePath := strings.TrimSpace(mustGetFlag(cmd, "file"))
if filePath == "" {
return apperrors.NewValidation("--file 不能为空")
}
info, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err))
}
if info.IsDir() {
return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath))
}
fileSize := info.Size()
fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name"))
if fileName == "" {
fileName = filepath.Base(filePath)
}
md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5"))
if md5Hex == "" {
md5Hex, err = computeFileMD5(filePath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err))
}
}
// --dry-run:本地只读工作(校验、Stat、大小、文件名、MD5)已完成,
// 在任何远程调用(init/PUT/commit)之前 early return,输出 plan 预览。
// 本命令是 RolloutUnifiedActive,必须经 output.StoreResult 存入统一结果,
// 不能直接 PrintJSON(否则框架报 "returned without a CommandResult")。
// 不伪造 uploadKey/resourceURL/fileId/spaceId——它们只能由远程调用返回。
if deps.Caller.DryRun() {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "attachment_upload",
"source": "oa",
"file": filePath,
"file_name": fileName,
"file_size": fileSize,
"md5": md5Hex,
"steps": []map[string]any{
{
"tool": "oa/init_attachment_upload_info",
"args": map[string]any{"fileName": fileName, "fileSize": fileSize, "md5": md5Hex},
"status": "planned",
},
{
"tool": "HTTP PUT",
"args": map[string]any{"file": filePath, "fileSize": fileSize},
"status": "planned",
},
{
"tool": "oa/commit_attachment_upload_info",
"args": map[string]any{"fileName": fileName, "fileSize": fileSize},
"requires": []string{"uploadKey from oa/init_attachment_upload_info"},
"status": "planned",
},
},
}, output.WithDryRun()))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
// Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。
initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
"md5": md5Hex,
})
if err != nil {
return err
}
resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText)
if err != nil {
return err
}
// Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
// Step 3: 提交上传信息完成入库,校验必需字段后以统一输出渲染 commit 结果。
commitData, err := CallMCPToolDataOnServer(ctx, "oa", "commit_attachment_upload_info", map[string]any{
"fileName": fileName,
"uploadKey": uploadKey,
"fileSize": float64(fileSize),
})
if err != nil {
return err
}
commitResp, ok := commitData.(map[string]any)
if !ok {
return apperrors.NewInternal("oa/commit_attachment_upload_info 返回值不是 JSON 对象")
}
commitResult, _ := commitResp["result"]
normalized, err := validateOAAttachmentCommitResult(commitResult)
if err != nil {
return err
}
return output.StoreResult(cmd.Context(), output.Success(normalized))
}
func newOAAttachmentCommand() *cobra.Command {
attachmentCmd := &cobra.Command{
Use: "attachment",
Short: "审批附件授权与下载链接",
Short: "审批附件授权、上传、下载与链接管理",
RunE: groupRunE,
}
@@ -319,7 +546,64 @@ func newOAAttachmentCommand() *cobra.Command {
},
})
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd)
uploadCmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)",
Long: `上传本地文件为审批附件(三步自动完成)。
流程:
1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info)
2. HTTP PUT 上传文件二进制到 OSS
3. 提交上传信息完成入库 (oa/commit_attachment_upload_info)
--file-name 不传时默认用文件名;--md5 不传时自动计算。`,
Example: ` dws oa approval attachment upload --file ./合同.pdf
dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx
dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`,
RunE: runOAAttachmentUpload,
}
DeclareLeafMetadata(uploadCmd, LeafSpec{
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "attachment_upload",
CanonicalPath: "oa.attachment_upload",
CLIPath: "oa approval attachment upload",
PrimaryCLIPath: "oa approval attachment upload",
},
Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`),
},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步",
UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"},
AvoidWhen: []string{
"仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview",
"仅需获取已有附件临时下载链接时使用 attachment download-url",
},
Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"},
},
},
})
uploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)")
uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)")
_ = uploadCmd.MarkFlagRequired("file")
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd)
return attachmentCmd
}
@@ -421,7 +705,7 @@ func validateOARequestTimeRange(request map[string]any) error {
// get_inst_revert_activities, get_process_schema, forecast_process,
// start_process_instance, get_process_instances_by_admin,
// get_attachment_download_url, auth_download_file,
// auth_preview_attachment
// auth_preview_attachment, init_attachment_upload_info, commit_attachment_upload_info
// ──────────────────────────────────────────────────────────
func newOaCommand() *cobra.Command {
+846
View File
@@ -10,7 +10,9 @@ import (
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
@@ -30,6 +32,7 @@ func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToo
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
cmd.PersistentFlags().Bool("dry-run", false, "仅预览不执行")
cmd.PersistentFlags().String("format", caller.Format(), "输出格式")
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
@@ -337,3 +340,846 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin
})
}
}
const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}`
func oaUploadCommitResponse(size int64) string {
return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` +
strconv.FormatInt(size, 10) +
`,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
}
// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。
func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
return path, int64(len(content))
}
// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的
// resourceURL 与签名 headers。
type capturedPut struct {
calls int
url string
headers map[string]string
filePath string
fileSize int64
}
func mockOAAttachmentPut(t *testing.T) *capturedPut {
t.Helper()
put := &capturedPut{}
SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error {
put.calls++
put.url = url
put.headers = headers
put.filePath = filePath
put.fileSize = fileSize
return nil
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
return put
}
func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) {
const content = "pdf-bytes-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content)
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "合同.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls)
}
if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" {
t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0])
}
wantInit := map[string]any{
"fileName": "合同.pdf",
"fileSize": float64(fileSize),
"md5": "d41d8cd98f00b204e9800998ecf8427e",
}
if !reflect.DeepEqual(caller.argsLog[0], wantInit) {
t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit)
}
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
if put.url != "https://oss.example.test/upload" {
t.Fatalf("PUT url = %q, want parsed resourceURL", put.url)
}
if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" {
t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers)
}
if put.filePath != filePath || put.fileSize != fileSize {
t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize)
}
if caller.toolLog[1] != "commit_attachment_upload_info" {
t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1])
}
wantCommit := map[string]any{
"fileName": "合同.pdf",
"uploadKey": "key-123",
"fileSize": float64(fileSize),
}
if !reflect.DeepEqual(caller.argsLog[1], wantCommit) {
t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit)
}
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 || data["fileId"] != "file-abc" {
t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"])
}
}
func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) {
const content = "auto-md5-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content)
wantMD5, err := fileMD5Hex(filePath)
if err != nil {
t.Fatalf("compute expected md5: %v", err)
}
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
// 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
); err != nil {
t.Fatalf("execute command: %v", err)
}
if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" {
t.Fatalf("default fileName = %#v, want basename report.xlsx", got)
}
if got := caller.argsLog[0]["md5"]; got != wantMD5 {
t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5)
}
if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) {
t.Fatalf("fileSize = %#v, want number %d", got, fileSize)
}
}
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing.T) {
tests := []struct {
name string
response string
wantErr string
}{
{
name: "missing result",
response: `{"success":true}`,
wantErr: "缺少 result",
},
{
name: "missing uploadKey",
response: `{"result":{"resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty uploadKey",
response: `{"result":{"uploadKey":" ","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty resourceUrls",
response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 resourceUrls",
},
{
name: "empty resourceUrls element",
response: `{"result":{"uploadKey":"key-1","resourceUrls":[""],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "首个 resourceUrls 为空",
},
{
name: "missing headers",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`,
wantErr: "缺少 headers",
},
{
name: "headers missing Authorization",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z"}},"success":true}`,
wantErr: "Authorization",
},
{
name: "headers missing x-oss-date",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"OSS sig"}},"success":true}`,
wantErr: "x-oss-date",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "test.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: test.response},
// second step should never be reached
{text: `{"result":{},"success":true}`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "test.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only, no commit)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("httpPutFile called %d times, want 0 (should not PUT)", put.calls)
}
})
}
}
func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) {
existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x")
tests := []struct {
name string
args []string
}{
{name: "missing file", args: []string{"approval", "attachment", "upload"}},
{name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}},
{name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller, test.args...)
if err == nil {
t.Fatalf("%s unexpectedly succeeded", test.name)
}
if caller.calls != 0 {
t.Fatalf("invalid upload made %d MCP call(s)", caller.calls)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut 验证
// --dry-run 在任何远程调用(init/PUT/commit)之前 early return:零 MCP 调用、
// 零 PUT,且输出一个包含 3 个 planned step 的 plan 预览。
func TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut(t *testing.T) {
const content = "dry-run-preview-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "plan.pdf", content)
wantMD5, err := fileMD5Hex(filePath)
if err != nil {
t.Fatalf("compute expected md5: %v", err)
}
put := mockOAAttachmentPut(t)
// caller.dry 驱动 deps.Caller.DryRun()==true;同时传入 --dry-run 以镜像生产路径。
caller := &scriptedToolCaller{format: "json", dry: true}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--dry-run",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry-run made %d MCP call(s), want 0", caller.calls)
}
if put.calls != 0 {
t.Fatalf("dry-run made %d PUT call(s), want 0", put.calls)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v\noutput: %s", err, stdout)
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("unified data missing/invalid: %#v", envelope["data"])
}
if data["dry_run"] != true || data["executed"] != false || data["preview_kind"] != "plan" {
t.Fatalf("plan flags = %#v", data)
}
if data["operation"] != "attachment_upload" || data["source"] != "oa" {
t.Fatalf("plan operation/source = %#v", data)
}
if data["file_name"] != "plan.pdf" || data["file_size"] != float64(fileSize) || data["md5"] != wantMD5 {
t.Fatalf("plan file metadata = %#v (want file_name=plan.pdf size=%d md5=%s)", data, fileSize, wantMD5)
}
steps, ok := data["steps"].([]any)
if !ok || len(steps) != 3 {
t.Fatalf("plan steps = %#v, want 3 planned steps", data["steps"])
}
wantTools := []string{"oa/init_attachment_upload_info", "HTTP PUT", "oa/commit_attachment_upload_info"}
for i, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
t.Fatalf("step %d not an object: %#v", i, raw)
}
if step["tool"] != wantTools[i] {
t.Fatalf("step %d tool = %#v, want %q", i, step["tool"], wantTools[i])
}
if step["status"] != "planned" {
t.Fatalf("step %d status = %#v, want planned", i, step["status"])
}
}
// The commit step (index 2) must NOT contain uploadKey in args (remote-only field)
// and must declare the dependency via a "requires" entry.
commitStep := steps[2].(map[string]any)
commitArgs, _ := commitStep["args"].(map[string]any)
if _, hasUploadKey := commitArgs["uploadKey"]; hasUploadKey {
t.Fatalf("commit step args must not contain uploadKey, got: %#v", commitArgs)
}
requires, ok := commitStep["requires"].([]any)
if !ok || len(requires) == 0 {
t.Fatalf("commit step must have non-empty requires, got: %#v", commitStep["requires"])
}
found := false
for _, r := range requires {
if s, ok := r.(string); ok && (len(s) > 0 && strings.Contains(s, "uploadKey") && strings.Contains(s, "init")) {
found = true
break
}
}
if !found {
t.Fatalf("commit step requires should mention uploadKey and init, got: %#v", requires)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure injects a failing computeFileMD5 to
// cover runOAAttachmentUpload's MD5 failure branch on all platforms (including Windows).
func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "test.bin", "hello")
put := mockOAAttachmentPut(t)
testseam.Swap(t, &computeFileMD5, func(string) (string, error) {
return "", errors.New("permission denied")
})
caller := &scriptedToolCaller{format: "json"}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
)
if err == nil {
t.Fatal("expected error from MD5 failure, got nil")
}
if !strings.Contains(err.Error(), "MD5") {
t.Fatalf("error should mention MD5, got: %v", err)
}
if caller.calls != 0 {
t.Fatalf("expected 0 MCP calls when MD5 fails, got %d", caller.calls)
}
if put.calls != 0 {
t.Fatalf("expected 0 PUT calls when MD5 fails, got %d", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError 注入一个返回错误的 PUT,
// 覆盖 runOAAttachmentUpload 中 httpPutFile 失败分支:init 后报错(1 次 MCP 调用)且不提交。
func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "put-fail.pdf", "data")
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
return errors.New("oss put failed")
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
// commit 不应被达到
{text: `{"result":{},"success":true}`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "put-fail.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "oss put failed") {
t.Fatalf("error = %v, want oss put failed", err)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only, no commit after PUT failure)", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse 校验 commit 返回
// 缺少必需字段时命令报错(而不是包装为成功)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse(t *testing.T) {
tests := []struct {
name string
commitResponse string
wantErr string
}{
{
name: "result is null",
commitResponse: `{"success":true,"result":null}`,
wantErr: "不是有效的 JSON 对象",
},
{
name: "result is empty object",
commitResponse: `{"success":true,"result":{}}`,
wantErr: "spaceId",
},
{
name: "missing fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100}}`,
wantErr: "fileId",
},
{
name: "empty fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100,"fileId":""}}`,
wantErr: "fileId",
},
{
name: "missing spaceId",
commitResponse: `{"success":true,"result":{"fileName":"a.pdf","fileSize":100,"fileId":"file-1"}}`,
wantErr: "spaceId",
},
{
name: "fileSize is zero",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":0,"fileId":"file-1"}}`,
wantErr: "fileSize",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-validate.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: test.commitResponse},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-validate.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
// init + commit = 2 MCP calls
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
// PUT should have been called (init succeeded)
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect 直接调用
// validateOAAttachmentCommitResult 覆盖所有分支路径,包括通过 end-to-end 流不可达的
// json.Number 类型分支。
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing.T) {
tests := []struct {
name string
input any
wantErr string // empty means expect nil error
}{
// result 不是 map
{name: "result is string", input: "hello", wantErr: "不是有效的 JSON 对象"},
{name: "result is array", input: []any{"x"}, wantErr: "不是有效的 JSON 对象"},
{name: "result is number", input: float64(42), wantErr: "不是有效的 JSON 对象"},
{name: "result is nil", input: nil, wantErr: "不是有效的 JSON 对象"},
// spaceId — 空字符串
{name: "spaceId empty string", input: map[string]any{
"spaceId": "", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
{name: "spaceId whitespace only", input: map[string]any{
"spaceId": " ", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// spaceId — json.Number(通过 UseNumber 解码的数字)
{name: "spaceId json.Number", input: map[string]any{
"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — float64(正常路径)
{name: "spaceId float64", input: map[string]any{
"spaceId": float64(123), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — 非法类型
{name: "spaceId bool", input: map[string]any{
"spaceId": true, "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// fileName — 非 string 类型
{name: "fileName is number", input: map[string]any{
"spaceId": "123", "fileName": float64(99), "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName is nil", input: map[string]any{
"spaceId": "123", "fileName": nil, "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName empty", input: map[string]any{
"spaceId": "123", "fileName": "", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
// fileSize — json.Number 有效
{name: "fileSize json.Number valid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("200"), "fileId": "file-1",
}, wantErr: ""},
// fileSize — json.Number 无效(<= 0)
{name: "fileSize json.Number zero", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("0"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number negative", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("-5"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number invalid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("abc"), "fileId": "file-1",
}, wantErr: "fileSize"},
// fileSize — 非法类型(default 分支)
{name: "fileSize is string", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": "100", "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize is nil", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": nil, "fileId": "file-1",
}, wantErr: "fileSize"},
// fileId — 非 string 类型
{name: "fileId is number", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": float64(99),
}, wantErr: "fileId"},
{name: "fileId whitespace", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": " ",
}, wantErr: "fileId"},
// 全部通过
{name: "all valid string types", input: map[string]any{
"spaceId": "123", "fileName": "report.pdf", "fileSize": float64(512), "fileId": "file-abc",
}, wantErr: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := validateOAAttachmentCommitResult(test.input)
if test.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath 覆盖 runOAAttachmentUpload
// 中 --file 传了但值为空字符串的分支(cobra MarkFlagRequired 只拦截未传,不拦截空值)。
func TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json"}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", "",
)
if err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
t.Fatalf("error = %v, want --file empty error", err)
}
if caller.calls != 0 {
t.Fatalf("MCP calls = %d, want 0", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadInitCallError 覆盖 runOAAttachmentUpload
// 中 callMCPToolReturnTextOnServer(init 步)返回错误的分支。
func TestCrossPlatformCoverageOAAttachmentUploadInitCallError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "init-err.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{err: errors.New("injected init failure")},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "init-err.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatal("expected error from init call failure, got nil")
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init attempted)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject 覆盖 runOAAttachmentUpload
// 中 commitData 不是 map[string]any 的分支(line 348)。
func TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-nonobj.pdf", "data")
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
// commit 返回一个 JSON 字符串而非对象
{text: `"not an object"`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-nonobj.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "不是 JSON 对象") {
t.Fatalf("error = %v, want commit non-object error", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON 覆盖
// parseOAAttachmentUploadInfo 中 json.Unmarshal 失败分支(init 返回非法 JSON)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "bad-init.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{not valid json`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "bad-init.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "解析 init_attachment_upload_info 返回失败") {
t.Fatalf("error = %v, want parse failure", err)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization 验证
// validateOAAttachmentCommitResult 对 spaceId/fileSize 的归一化行为。
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization(t *testing.T) {
tests := []struct {
name string
input map[string]any
wantSpaceID any
wantSize any
wantErr string
}{
{
name: "string spaceId normalized to int64",
input: map[string]any{"spaceId": "27827223951", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: int64(27827223951),
wantSize: float64(100),
},
{
name: "non-numeric string spaceId returns error",
input: map[string]any{"spaceId": "abc", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "float string spaceId returns error",
input: map[string]any{"spaceId": "12.5", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "json.Number spaceId normalized to int64",
input: map[string]any{"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: int64(27827223951),
wantSize: float64(100),
},
{
name: "json.Number spaceId non-integer returns error",
input: map[string]any{"spaceId": json.Number("12.5"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "json.Number fileSize normalized to int64",
input: map[string]any{"spaceId": float64(123), "fileName": "a.pdf", "fileSize": json.Number("2048"), "fileId": "file-1"},
wantSpaceID: float64(123),
wantSize: int64(2048),
},
{
name: "float64 spaceId unchanged",
input: map[string]any{"spaceId": float64(27827223951), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: float64(27827223951),
wantSize: float64(100),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
normalized, err := validateOAAttachmentCommitResult(test.input)
if test.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if normalized["spaceId"] != test.wantSpaceID {
t.Fatalf("spaceId = %v (%T), want %v (%T)", normalized["spaceId"], normalized["spaceId"], test.wantSpaceID, test.wantSpaceID)
}
if normalized["fileSize"] != test.wantSize {
t.Fatalf("fileSize = %v (%T), want %v (%T)", normalized["fileSize"], normalized["fileSize"], test.wantSize, test.wantSize)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadOutputContract 验证端到端上传命令
// 输出 JSON 中 spaceId 始终为 number(即使 commit 返回字符串 spaceId)。
func TestCrossPlatformCoverageOAAttachmentUploadOutputContract(t *testing.T) {
tests := []struct {
name string
commitResponse string
wantSpaceID float64 // JSON decode 后 number → float64
}{
{
name: "spaceId as number",
commitResponse: `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
wantSpaceID: float64(27827223951),
},
{
name: "spaceId as string",
commitResponse: `{"result":{"spaceId":"27827223951","fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
wantSpaceID: float64(27827223951),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
const content = "output-contract-data"
filePath, _ := writeOAAttachmentTempFile(t, "contract.pdf", content)
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: test.commitResponse},
}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "合同.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
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" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("unified data missing: %#v", envelope)
}
// spaceId must be a number in the JSON output
spaceID, ok := data["spaceId"].(float64)
if !ok {
t.Fatalf("spaceId is not a number: %v (%T)", data["spaceId"], data["spaceId"])
}
if spaceID != test.wantSpaceID {
t.Fatalf("spaceId = %v, want %v", spaceID, test.wantSpaceID)
}
// fileSize must also be a number
if _, ok := data["fileSize"].(float64); !ok {
t.Fatalf("fileSize is not a number: %v (%T)", data["fileSize"], data["fileSize"])
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、
// commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支:
// PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。
func TestCrossPlatformCoverageOAAttachmentUploadCommitError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-fail.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{err: errors.New("commit upload failed")},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-fail.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "commit upload failed") {
t.Fatalf("error = %v, want commit upload failed", err)
}
if put.calls != 1 {
t.Fatalf("PUT calls = %d, want 1", put.calls)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit attempted)", caller.calls)
}
if caller.toolLog[0] != "init_attachment_upload_info" || caller.toolLog[1] != "commit_attachment_upload_info" {
t.Fatalf("tool sequence = %v, want init then commit", caller.toolLog)
}
}
@@ -27,6 +27,11 @@ type scriptedToolCaller struct {
server string
tool string
args map[string]any
// Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit)
// can assert each invocation instead of only the last one captured above.
serverLog []string
toolLog []string
argsLog []map[string]any
}
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
@@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri
c.server = serverID
c.tool = toolName
c.args = args
c.serverLog = append(c.serverLog, serverID)
c.toolLog = append(c.toolLog, toolName)
c.argsLog = append(c.argsLog, args)
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
+6 -1
View File
@@ -26,10 +26,11 @@ func newSheetCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "sheet",
Selection: contract.ProductSelectionDecl{
AgentSummary: "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
AgentSummary: "导入本地 Excel,或创建、读取、编辑、导出和审计钉钉在线电子表格(axls),并管理工作表、区域、历史 revision、筛选、图表、图片与格式。",
UseWhen: []string{
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时",
"用户要查询工作簿当前 revision 或复核两个 revision 之间的 changeset 时",
},
AvoidWhen: []string{
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
@@ -110,6 +111,8 @@ func newSheetCommand() *cobra.Command {
dws sheet chart create 创建浮动图表
dws sheet chart update 更新浮动图表
dws sheet chart delete 删除浮动图表
dws sheet revision-get 获取工作簿当前 revision
dws sheet changeset-get 获取工作簿 revision 区间内的 changeset
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
dws sheet import 导入 xlsx/xls 为在线电子表格
@@ -169,6 +172,7 @@ func newSheetCommand() *cobra.Command {
templateCmd := newSheetTemplateCmd()
tableCmds := newTableCmds()
pivotTableCmd := newPivotTableCmd()
revisionCmds := newSheetRevisionCmds()
batchUpdateCmd := newBatchUpdateCmd()
DeclareLeafMetadata(batchUpdateCmd, LeafSpec{
@@ -244,6 +248,7 @@ func newSheetCommand() *cobra.Command {
standaloneCmds = append(standaloneCmds, mediaCmds...)
standaloneCmds = append(standaloneCmds, floatImageCmds...)
standaloneCmds = append(standaloneCmds, tableCmds...)
standaloneCmds = append(standaloneCmds, revisionCmds...)
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
// Register cross-product aliases
+4
View File
@@ -1,6 +1,7 @@
package helpers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -154,6 +155,9 @@ func newSheetCommentCmd() *cobra.Command {
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
return err
}
toolArgs["emoji"] = true
}
if v, _ := cmd.Flags().GetString("mention"); v != "" {
@@ -93,7 +93,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
"comment", "reply",
"--node", "node-1",
"--comment-key", "ck-1",
"--content", "heart",
"--content", "比心",
"--emoji",
"--mention", "uid1",
},
@@ -102,7 +102,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
toolName: "reply_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "heart",
"content": "比心",
"replyCommentKey": "ck-1",
"emoji": true,
"mentionedUserIds": []string{"uid1"},
+783
View File
@@ -0,0 +1,783 @@
// 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"
"io"
"reflect"
"sort"
"strconv"
"strings"
"sync"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"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"
)
const (
sheetRevisionGetRemoteTool = "get_sheet_revision"
sheetChangesetGetRemoteTool = "get_sheet_changeset"
sheetChangesetMaxSpan = int64(20)
sheetChangesetJSONMaxBytes = 2 * 1024 * 1024
)
var sheetRevisionResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"工作簿当前持久化 Delta revision",
"properties":{
"success":{"type":"boolean","description":"服务端业务调用是否成功"},
"logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
"revision":{"type":"number","description":"当前工作簿 revision;空工作簿为 0"}
},
"required":["success","logId","revision"],
"additionalProperties":true
}`),
}
var sheetChangesetResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"工作簿 revision 区间内连续、按 revision 升序排列的 V2 前向语义 changeset",
"properties":{
"success":{"type":"boolean","description":"服务端业务调用是否成功"},
"logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
"schemaVersion":{"type":"number","description":"changeset 业务响应的语义版本;当前固定为 2","enum":[2]},
"changeSemantics":{"type":"string","description":"变更详情只描述当时提交的前向效果,不提供统一 old/current 值;当前固定为 FORWARD_ONLY","enum":["FORWARD_ONLY"]},
"latestRevision":{"type":"number","description":"请求开始时观测并固定的工作簿最新 revision"},
"startRevision":{"type":"number","description":"查询基线 revision;结果不包含该 revision"},
"endRevision":{"type":"number","description":"本次实际查询的结束 revision;结果包含该 revision"},
"summary":{
"type":"object",
"description":"本次区间内语义 change 的完整性和影响范围汇总",
"properties":{
"changeCount":{"type":"number","description":"全部 changesets 中 change 对象的总数;STATE_RESET 不计作普通 change"},
"completeChangeCount":{"type":"number","description":"detailsStatus 为 COMPLETE 的 change 数量"},
"partialChangeCount":{"type":"number","description":"detailsStatus 为 PARTIAL 的 change 数量"},
"unsupportedChangeCount":{"type":"number","description":"type 为 UNSUPPORTED_CHANGE 的 change 数量"},
"containsStateReset":{"type":"boolean","description":"区间内是否包含 STATE_RESET 事件"},
"containsIncompleteChanges":{"type":"boolean","description":"区间内是否包含 PARTIAL、UNAVAILABLE 或 UNSUPPORTED_CHANGE"},
"affectedSheets":{
"type":"array",
"description":"由 AFFECTED 或 DESTINATION target 去重排序得到的工作表与 A1 范围摘要;SOURCE 不计入",
"items":{
"type":"object",
"properties":{
"sheetId":{"type":"string","description":"受影响工作表的稳定 ID"},
"sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称;缺失时不得由 sheetId 猜测"},
"ranges":{"type":"array","description":"去重排序后的 1-based A1 范围;工作表级变更为空数组","items":{"type":"string","description":"1-based A1 范围;整行或整列可写为 1:3 或 A:C"}}
},
"required":["sheetId","ranges"],
"additionalProperties":true
}
}
},
"required":["changeCount","completeChangeCount","partialChangeCount","unsupportedChangeCount","containsStateReset","containsIncompleteChanges","affectedSheets"],
"additionalProperties":true
},
"changesets":{
"type":"array",
"description":"区间 (startRevision, endRevision] 内按 revision 升序排列的完整事件",
"items":{
"type":"object",
"properties":{
"revision":{"type":"number","description":"该 changeset 对应的 Delta revision"},
"createTime":{"type":"string","description":"该 revision 的创建时间"},
"isSelfEdit":{"type":"boolean","description":"该 Delta 是否由当前请求用户提交;false 不能用于识别具体编辑者"},
"eventType":{"type":"string","description":"事件类型;EDIT 为普通前向编辑,UNDO 为撤销提交产生的前向效果,STATE_RESET 为全量状态替换点","enum":["EDIT","UNDO","STATE_RESET"]},
"detailsStatus":{"type":"string","description":"该事件内所有 change 的最差详情完整度;COMPLETE、PARTIAL 或 UNAVAILABLE","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
"reset":{
"type":"object",
"description":"STATE_RESET 的状态替换信息;EDIT 和 UNDO 不返回该对象",
"properties":{
"type":{"type":"string","description":"状态替换原因类型","enum":["ROLLBACK","OVERWRITE","UPGRADE","TEMPLATE","PRETTIFY","UNKNOWN_RESET"]},
"targetRevision":{"type":"number","description":"targetStatus 为 KNOWN 时的目标 revision;0 是合法空基线"},
"targetStatus":{"type":"string","description":"目标 revision 状态;KNOWN 表示已返回,NOT_APPLICABLE 表示该 reset 没有目标,UNAVAILABLE 表示应有目标但无法确认","enum":["KNOWN","NOT_APPLICABLE","UNAVAILABLE"]}
},
"required":["type","targetStatus"],
"additionalProperties":true
},
"changes":{
"type":"array",
"description":"该 revision 内按提交顺序排列的前向语义 change;STATE_RESET 固定为空数组",
"items":{
"type":"object",
"properties":{
"type":{"type":"string","description":"Agent 可解释的前向变更类型","enum":["ROWS_INSERTED","ROWS_DELETED","ROWS_UPDATED","COLUMNS_INSERTED","COLUMNS_DELETED","COLUMNS_UPDATED","SHEET_CREATED","SHEET_DELETED","SHEET_UPDATED","CUSTOM_TAB_ADDED","CUSTOM_TAB_DELETED","CUSTOM_TAB_UPDATED","CELLS_INSERTED","CELLS_DELETED","RANGE_PASTED","RANGE_AUTOFILLED","RANGE_CLEARED","RANGE_CONTENT_SET","RANGE_BORDER_SET","RANGE_STYLE_SET","RANGE_TAG_SET","RANGE_SORTED","CELLS_CONTENT_SET","CELLS_STYLE_SET","CELLS_TAG_SET","DIMENSION_GROUP_ADDED","DIMENSION_GROUP_REMOVED","DIMENSION_GROUP_UPDATED","DATA_VALIDATION_SET","DATA_VALIDATION_CLEARED","CELLS_MERGED","CELLS_UNMERGED","NAMED_RANGE_SET","NAMED_RANGE_CLEARED","FEATURE_ADDED","FEATURE_DELETED","FEATURE_UPDATED","WORKBOOK_SETTING_UPDATED","EXTERNAL_REFERENCES_REPLACED","UNSUPPORTED_CHANGE"]},
"targets":{
"type":"array",
"description":"该 change 涉及的工作簿、工作表或范围;源和目的范围用 role 区分",
"items":{
"type":"object",
"properties":{
"scope":{"type":"string","description":"定位层级","enum":["WORKBOOK","SHEET","RANGE"]},
"sheetId":{"type":"string","description":"SHEET 或 RANGE target 的稳定工作表 ID"},
"sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称"},
"sheetNameSource":{"type":"string","description":"工作表名称来源;AT_CHANGE 为变更时名称,CURRENT_STATE 为当前状态映射,UNKNOWN 为无法确认","enum":["AT_CHANGE","CURRENT_STATE","UNKNOWN"]},
"a1Range":{"type":"string","description":"RANGE target 的 1-based A1 范围"},
"role":{"type":"string","description":"target 在变更中的角色;省略时等价于 AFFECTED","enum":["AFFECTED","SOURCE","DESTINATION"]}
},
"required":["scope"],
"additionalProperties":true
}
},
"details":{
"type":"object",
"description":"按 change.type 归一后的前向详情;字段集合见 Sheet Skill,未返回的旧值或当前值不得推断",
"properties":{
"cell":{
"type":"object",
"description":"前向单元格内容;整体清除时为 {cleared:true}",
"properties":{
"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},
"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},
"formula":{"type":"string","description":"本次修改写入的可读公式文本"},
"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},
"cellType":{"type":"object","description":"单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
"link":{"type":"object","description":"工作簿内范围链接;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}
},
"additionalProperties":true
},
"cellType":{"type":"object","description":"相对内容变更的单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false},
"border":{"type":"object","description":"前向边框;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个边框对象被清除","enum":[true]},"color":{"description":"边框颜色;已知属性为 null 表示清除该属性"},"style":{"description":"边框线型;已知属性为 null 表示清除该属性"}},"additionalProperties":true},
"style":{"type":"object","description":"前向样式白名单;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true},
"properties":{"type":"object","description":"行列、工作表或自定义 Tab 的前向属性;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个属性对象被清除","enum":[true]}},"additionalProperties":true},
"changes":{"type":"object","description":"工作簿设置的前向字段;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个设置对象被清除","enum":[true]},"mode":{"type":"string","description":"CALCULATION 的重算模式;default 表示恢复默认模式","enum":["auto","autoNoTable","manual","default"]},"iterate":{"type":["boolean","null"],"description":"CALCULATION 是否启用迭代计算;null 表示清除该设置"},"iterateCount":{"type":["number","null"],"description":"CALCULATION 的最大迭代次数;null 表示清除该设置"},"iterateDelta":{"type":["number","null"],"description":"CALCULATION 的迭代收敛阈值;null 表示清除该设置"},"enableDynamicArray":{"type":["boolean","null"],"description":"CALCULATION 是否启用动态数组;null 表示清除该设置"},"date1904":{"type":["boolean","null"],"description":"CALCULATION 是否使用 1904 日期系统;null 表示清除该设置"},"image":{"type":"object","description":"BACKGROUND 图片设置;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示背景图片设置被清除","enum":[true]},"opacity":{"type":["number","null"],"description":"背景图片不透明度;null 表示清除该属性"}},"additionalProperties":true}},"additionalProperties":true},
"fillMode":{"type":"string","description":"RANGE_AUTOFILLED 的填充模式","enum":["copy","series","trend","predict","none"]},
"copyStyle":{"type":"boolean","description":"RANGE_AUTOFILLED 是否复制样式;当前协议的 COMPLETE change 必有"},
"styleMode":{"type":"string","description":"CELLS_STYLE_SET 的样式作用模式;coverStyle 未传 mode 时服务端显式返回 cell","enum":["sheet","row","col","cell"]},
"step":{"type":"object","description":"粘贴或自动填充模式块大小","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"}},"required":["rows","columns"],"additionalProperties":true},
"pasteMode":{"type":"string","description":"RANGE_PASTED 的粘贴作用模式","enum":["cell","row","col","sheet"]},
"isCut":{"type":"boolean","description":"RANGE_PASTED 是否来自剪切"},
"iterateMode":{"type":"string","description":"RANGE_PASTED 如何重复应用内容模式","enum":["step","flex"]},
"includedParts":{"type":"array","description":"RANGE_PASTED 实际包含非空数据的变更类别;空切片不计入","items":{"type":"string","description":"实际包含的变更类别","enum":["CONTENT","STYLE","MERGES","CELL_TYPES","CONDITIONAL_FORMATTING","TABLES","PIVOT_TABLES","COMMENTS","REMINDERS","MENTIONS","DATA_VALIDATION","FILTERS","LOCKS","FOLLOWERS","PROTECTION_RANGES","DIMENSION_METADATA","REACTIONS","RANGE_TAGS"]}},
"contentPattern":{"type":"object","description":"粘贴内容模式的安全化前向表示","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"},"cells":{"type":"array","description":"相对模式块的单元格内容","items":{"type":"object","properties":{"rowOffset":{"type":"number","description":"相对模式块左上角的 0-based 行偏移"},"columnOffset":{"type":"number","description":"相对模式块左上角的 0-based 列偏移"},"cleared":{"type":"boolean","description":"true 表示整个模式单元格被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"required":["rowOffset","columnOffset"],"additionalProperties":true}}},"required":["cells"],"additionalProperties":true},
"clearParts":{"type":"array","description":"RANGE_CLEARED 实际清除的内容类别","items":{"type":"string","description":"被清除的内容类别","enum":["VALUES","FORMULAS","MERGES","STYLES","CELL_TYPES","CONDITIONAL_FORMATTING","DATA_VALIDATION","DATA_VALIDATION_LIST","COLUMN_TYPES","LINKS","TABLES","COMMENTS","REMINDERS","REACTIONS"]}},
"preservedCellTypes":{"type":"array","description":"清除 CELL_TYPES 时明确保留的类型;空数组表示不保留 select 或 checkbox","items":{"type":"string","description":"保留的单元格类型","enum":["SELECT","CHECKBOX"]}},
"relativeChanges":{"type":"array","description":"相对 target 定位的前向行列属性、内容、样式或 tag 变更;sheet 级项可没有坐标","items":{"type":"object","properties":{"offset":{"type":"number","description":"ROWS_UPDATED 或 COLUMNS_UPDATED 中相对行列区间起点的 0-based 偏移"},"properties":{"type":"object","description":"offset 对应的前向行列属性或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个行列属性对象被清除","enum":[true]},"size":{"type":["number","null"],"description":"行高或列宽;null 表示清除该属性"},"customSize":{"type":["boolean","null"],"description":"是否使用自定义尺寸;null 表示清除该属性"},"hidden":{"type":["boolean","null"],"description":"是否隐藏;null 表示清除该属性"},"sticky":{"type":["boolean","null"],"description":"是否冻结;null 表示清除该属性"},"cellType":{"type":"object","description":"行列默认单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}},"additionalProperties":true},"rowOffset":{"type":"number","description":"0-based 行偏移;行级或单元格级项使用"},"columnOffset":{"type":"number","description":"0-based 列偏移;列级或单元格级项使用"},"cell":{"type":"object","description":"前向单元格内容或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"additionalProperties":true},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]}},"additionalProperties":true},"styleId":{"type":"string","description":"引用同一 change 的 styles[] 条目的 ID"},"styleCleared":{"type":"boolean","description":"true 表示该相对位置的样式被清除","enum":[true]},"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false}},"additionalProperties":true}},
"styles":{"type":"array","description":"同一 change 内可由 relativeChanges.styleId 引用的具名样式条目","items":{"type":"object","properties":{"styleId":{"type":"string","description":"样式引用 ID"},"style":{"type":"object","description":"样式白名单对象;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true}},"required":["styleId","style"],"additionalProperties":true}},
"dataValidation":{
"type":"object",
"description":"DATA_VALIDATION_SET 的归一化验证规则;敏感或无法安全解释的内部字段不会返回",
"properties":{
"type":{"type":"string","description":"数据验证规则类型"},
"templateId":{"type":"number","description":"列表验证使用的模板 ID;存在时不代表已返回内联选项或来源区域"},
"sourceType":{"type":"string","description":"下拉选项来源;inline 为内联选项,sourceRange 为区域引用","enum":["inline","sourceRange"]},
"options":{"type":"array","description":"安全化后的下拉选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}},
"sourceRange":{"type":"object","description":"解析成功时的下拉来源区域","properties":{"sheetId":{"type":"string","description":"来源工作表 ID"},"sheetName":{"type":"string","description":"可用时的来源工作表名称"},"a1Notation":{"type":"string","description":"来源区域的 1-based A1 表示"}},"additionalProperties":true},
"sourceRangeStatus":{"type":"string","description":"来源区域解析状态","enum":["RESOLVED","UNRESOLVED","INVALID"]},
"sourceRangeExpression":{"type":"string","description":"安全来源区域表达式;解析为 RESOLVED 时也可能保留原表达式"},
"enableMultiSelect":{"type":"boolean","description":"下拉是否允许多选"},
"criteria":{"type":"object","description":"非下拉规则可安全公开的具名条件参数","properties":{"operator":{"type":"string","description":"条件运算符"},"value1":{"type":"object","description":"第一个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"value2":{"type":"object","description":"第二个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"条件公式"}},"additionalProperties":true},
"settings":{"type":"object","description":"可安全公开的数据验证通用行为设置;已知属性为 null 表示清除该属性","properties":{"allowBlank":{"type":["boolean","null"],"description":"是否允许空值;null 表示清除该设置"},"errorStyle":{"type":["string","null"],"description":"验证失败时的错误样式;null 表示清除该设置"},"showInputMessage":{"type":["boolean","null"],"description":"是否显示输入提示;null 表示清除该设置"},"showErrorMessage":{"type":["boolean","null"],"description":"是否显示错误提示;null 表示清除该设置"},"prompt":{"type":["string","null"],"description":"输入提示文本;null 表示清除该设置"},"error":{"type":["string","null"],"description":"验证失败提示文本;null 表示清除该设置"},"requiredInRow":{"type":["boolean","null"],"description":"是否要求行内必填;null 表示清除该设置"},"showDropDown":{"type":["boolean","null"],"description":"是否显示下拉控件;null 表示清除该设置"},"columnType":{"type":["boolean","null"],"description":"是否启用列类型行为;null 表示清除该设置"},"promptTitle":{"type":["string","null"],"description":"输入提示标题;null 表示清除该设置"},"errorTitle":{"type":["string","null"],"description":"验证失败提示标题;null 表示清除该设置"},"imeMode":{"type":["string","null"],"description":"输入法模式;null 表示清除该设置"}},"additionalProperties":true}
},
"required":["type"],
"additionalProperties":true
}
},
"additionalProperties":true
},
"detailsStatus":{"type":"string","description":"details 的完整度;COMPLETE 仍只代表前向提交详情,不代表 old/current 状态完整","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
"omissions":{
"type":"array",
"description":"PARTIAL 或 UNAVAILABLE 时的稳定省略原因;COMPLETE 时固定为空数组",
"items":{"type":"object","properties":{"code":{"type":"string","description":"稳定的省略原因码","enum":["MISSING_REQUIRED_FIELD","PLUGIN_OPERATION","UNSUPPORTED_WRAPPER","UNSUPPORTED_PROTOCOL_VERSION","UNKNOWN_ACTION","UNKNOWN_VARIANT","DETAILS_NOT_FULLY_INTERPRETED","INVALID_TARGET","VALUE_NOT_AGENT_READABLE","FORMULA_TEXT_UNAVAILABLE","SOURCE_RANGE_UNRESOLVED","SOURCE_RANGE_INVALID","SENSITIVE_DETAILS_OMITTED"]},"fields":{"type":"array","description":"受该原因影响的 details 字段路径","items":{"type":"string","description":"被省略或不完整的字段路径"}}},"required":["code"],"additionalProperties":true}
}
},
"required":["type","targets","details","detailsStatus","omissions"],
"additionalProperties":true
}
}
},
"required":["revision","createTime","isSelfEdit","eventType","detailsStatus","changes"],
"additionalProperties":true
}
}
},
"required":["success","logId","schemaVersion","changeSemantics","latestRevision","startRevision","endRevision","summary","changesets"],
"additionalProperties":true
}`),
}
type sheetPublishedResultSchemaCache struct {
once sync.Once
schema map[string]any
err error
}
var (
sheetRevisionPublishedSchemaCache sheetPublishedResultSchemaCache
sheetChangesetPublishedSchemaCache sheetPublishedResultSchemaCache
)
func newSheetRevisionCmds() []*cobra.Command {
return []*cobra.Command{newSheetRevisionGetCmd(), newSheetChangesetGetCmd()}
}
func newSheetRevisionGetCmd() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "revision-get",
Short: "获取表格工作簿当前 revision",
Long: "获取在线电子表格工作簿当前持久化 revision。该能力是工作簿级的,不接收 --sheet-id;--node 可传文档 ID 或完整 URL。服务端业务 JSON 原样放入统一输出的 data。",
Example: " dws sheet revision-get --node <NODE_ID_OR_URL> --format json",
OutputRollout: output.RolloutUnifiedActive,
Server: "sheet",
Tool: sheetRevisionGetRemoteTool,
Flags: []LeafFlag{
{Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: sheetRevisionGetRemoteTool,
CanonicalPath: "sheet." + sheetRevisionGetRemoteTool,
CLIPath: "sheet revision-get",
PrimaryCLIPath: "sheet revision-get",
},
Description: "获取在线电子表格工作簿当前持久化 revision;结果为后续 changeset 区间查询的 revision 锚点。",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
Result: sheetRevisionResult,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetRevisionGetRemoteTool},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取在线电子表格工作簿当前 Delta revision,作为 changeset 查询锚点。",
UseWhen: []string{"需要知道当前 revision,或准备按 revision 区间复核工作簿编辑时"},
AvoidWhen: []string{"要查看可命名/可回滚的历史快照时用 sheet version list;要读当前单元格值时用 csv-get、table-get 或 range read"},
Examples: []string{"dws sheet revision-get --node <NODE_ID_OR_URL> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
},
},
ResultCall: callSheetRevisionResult,
})
}
func newSheetChangesetGetCmd() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "changeset-get",
Short: "获取表格工作簿 revision 区间内的 changeset",
Long: `获取在线电子表格工作簿在 (startRevision, endRevision] 区间内的连续、Agent 可解释的前向 changeset。
该能力是工作簿级的,不接收 --sheet-id。--end-revision 省略时由服务端在请求开始时固定为 latestRevision;单次区间最多 20 个 revision。统一输出 data.changesets 始终是可直接遍历的 JSON 数组;CLI 只解码服务端传输格式,不改写语义化 change。changeset 只描述当时提交的前向变更,不提供统一 old/current 值;确认最终状态必须另行回读。`,
Example: ` dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --end-revision 121 --format json
dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --format json`,
OutputRollout: output.RolloutUnifiedActive,
Server: "sheet",
Tool: sheetChangesetGetRemoteTool,
Flags: []LeafFlag{
{Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
{
Name: "start-revision", Usage: "查询基线 revision (必填,非负;结果不包含该 revision)", Bind: "startRevision",
Required: true, Trim: true, Example: "120", Transform: sheetRevisionNumberArg,
},
{
Name: "end-revision", Usage: "查询结束 revision (可选,非负;结果包含该 revision)", Bind: "endRevision",
Trim: true, OmitEmpty: true, Example: "121", Transform: sheetRevisionNumberArg,
},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: sheetChangesetGetRemoteTool,
CanonicalPath: "sheet." + sheetChangesetGetRemoteTool,
CLIPath: "sheet changeset-get",
PrimaryCLIPath: "sheet changeset-get",
},
Description: "获取在线电子表格工作簿 revision 区间内连续、语义化的前向 changeset;区间语义为 (startRevision, endRevision]。",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
Result: sheetChangesetResult,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetChangesetGetRemoteTool},
},
Selection: contract.SelectionSpec{
AgentSummary: "读取工作簿两个 revision 之间的语义化前向 changeset,区分 EDIT、UNDO 与 STATE_RESET。",
UseWhen: []string{"已知起始 revision,需要复核之后发生了哪些工作簿级编辑或回滚时"},
AvoidWhen: []string{"要读取单元格当前最终值时用 csv-get、table-get 或 range read;要查看或回滚命名历史快照时用 sheet version list/revert"},
Examples: []string{"dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --end-revision 121 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
{Name: "start-revision", Property: "startRevision", InterfaceType: "number", Description: "非负查询基线;返回区间不包含该 revision"},
{Name: "end-revision", Property: "endRevision", InterfaceType: "number", Description: "可选非负结束 revision;返回区间包含该 revision"},
},
},
Validate: validateSheetChangesetRange,
ResultCall: callSheetRevisionResult,
})
}
func callSheetRevisionResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
if deps.Caller.DryRun() {
return output.Success(map[string]any{
"executed": false,
"tool": tool,
"arguments": args,
}, output.WithDryRun()), nil
}
raw, err := callMCPToolReturnTextOnServer(cmd.Context(), "sheet", tool, args)
if err != nil {
return nil, err
}
data, err := decodeSheetRevisionResult(tool, args, raw)
if err != nil {
return nil, err
}
return output.Success(data), nil
}
func decodeSheetRevisionResult(tool string, request map[string]any, raw string) (any, error) {
if strings.TrimSpace(raw) == "" {
return nil, invalidSheetRevisionResponse(tool,
"MCP sheet read tool returned no non-empty text content",
"empty_tool_response",
true,
)
}
data, err := decodeSheetSingleJSON(raw)
if err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned invalid JSON: %v", err),
"invalid_tool_response",
false,
)
}
object, ok := data.(map[string]any)
if !ok {
return nil, invalidSheetRevisionResponse(tool,
"MCP sheet read tool returned a non-object business result",
"invalid_tool_response",
false,
)
}
if isBusinessError(object) {
return nil, &CLIError{
Code: CodeMCPToolError,
Message: raw,
Suggestion: suggestForBusinessError(object),
Operation: "sheet/" + tool,
}
}
success, ok := object["success"].(bool)
if !ok || !success {
return nil, invalidSheetRevisionResponse(tool,
"MCP sheet read tool returned a business result without success=true",
"invalid_tool_response",
false,
)
}
if tool == sheetChangesetGetRemoteTool {
if err := normalizeSheetChangesetTransport(object); err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned invalid changeset data: %v%s",
err, sheetResultLogIDSuffix(object)),
"invalid_tool_response",
false,
)
}
}
if err := validateSheetPublishedResult(tool, object); err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned data that does not match its published result contract: %v%s",
err, sheetResultLogIDSuffix(object)),
"invalid_tool_response",
false,
)
}
if tool == sheetChangesetGetRemoteTool {
if err := validateSheetChangesetAudit(request, object); err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned changeset data that does not match the requested interval or its summary: %v%s",
err, sheetResultLogIDSuffix(object)),
"invalid_tool_response",
false,
)
}
}
return object, nil
}
func invalidSheetRevisionResponse(tool, message, reason string, retryable bool) error {
return apperrors.NewAPI(message,
apperrors.WithOperation("sheet/"+tool),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(retryable),
apperrors.WithReason(reason),
)
}
func sheetResultLogIDSuffix(object map[string]any) string {
logID, ok := object["logId"].(string)
if !ok || strings.TrimSpace(logID) == "" {
return ""
}
return fmt.Sprintf(" (logId=%s)", strings.TrimSpace(logID))
}
func decodeSheetSingleJSON(raw string) (any, error) {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
var data any
if err := decoder.Decode(&data); err != nil {
return nil, err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
err = fmt.Errorf("包含多个 JSON 值")
}
return nil, err
}
return data, nil
}
func normalizeSheetChangesetTransport(object map[string]any) error {
encoded, hasEncoded := object["changesetsJson"]
if hasEncoded {
changesetsJSON, ok := encoded.(string)
if !ok {
return fmt.Errorf("changesetsJson 不是字符串")
}
if strings.TrimSpace(changesetsJSON) == "" {
return fmt.Errorf("changesetsJson 为空")
}
if len(changesetsJSON) > sheetChangesetJSONMaxBytes {
return fmt.Errorf("changesetsJson 超过 %d 字节", sheetChangesetJSONMaxBytes)
}
decoded, err := decodeSheetSingleJSON(changesetsJSON)
if err != nil {
return fmt.Errorf("changesetsJson 不是完整 JSON: %v", err)
}
changesets, ok := decoded.([]any)
if !ok {
return fmt.Errorf("changesetsJson 根节点不是数组")
}
object["changesets"] = changesets
delete(object, "changesetsJson")
return nil
}
if legacy, exists := object["changesets"]; exists {
if _, ok := legacy.([]any); !ok {
return fmt.Errorf("changesets 不是数组")
}
return nil
}
return fmt.Errorf("成功响应缺少 changesetsJson")
}
func validateSheetPublishedResult(tool string, object map[string]any) error {
var rawSchema json.RawMessage
var cache *sheetPublishedResultSchemaCache
switch tool {
case sheetRevisionGetRemoteTool:
rawSchema = sheetRevisionResult.DataSchema
cache = &sheetRevisionPublishedSchemaCache
case sheetChangesetGetRemoteTool:
rawSchema = sheetChangesetResult.DataSchema
cache = &sheetChangesetPublishedSchemaCache
default:
return fmt.Errorf("未知工具 %q", tool)
}
return validateSheetPublishedResultWithSchema(tool, object, rawSchema, cache)
}
func validateSheetPublishedResultWithSchema(tool string, object map[string]any,
rawSchema json.RawMessage, cache *sheetPublishedResultSchemaCache,
) error {
cache.once.Do(func() { cache.err = json.Unmarshal(rawSchema, &cache.schema) })
if cache.err != nil {
return fmt.Errorf("读取已发布 Result Schema 失败: %v", cache.err)
}
schema := cache.schema
if err := cli.ValidateJSONSchemaValue(object, schema); err != nil {
return err
}
if strings.TrimSpace(object["logId"].(string)) == "" {
return fmt.Errorf("$.logId 不能为空")
}
switch tool {
case sheetRevisionGetRemoteTool:
if _, err := sheetNonNegativeInteger(object["revision"]); err != nil {
return fmt.Errorf("$.revision %v", err)
}
case sheetChangesetGetRemoteTool:
latest, err := sheetNonNegativeInteger(object["latestRevision"])
if err != nil {
return fmt.Errorf("$.latestRevision %v", err)
}
start, err := sheetNonNegativeInteger(object["startRevision"])
if err != nil {
return fmt.Errorf("$.startRevision %v", err)
}
end, err := sheetNonNegativeInteger(object["endRevision"])
if err != nil {
return fmt.Errorf("$.endRevision %v", err)
}
if start > end {
return fmt.Errorf("$.startRevision 不能大于 $.endRevision")
}
if end > latest {
return fmt.Errorf("$.endRevision 不能大于 $.latestRevision")
}
}
return nil
}
type sheetChangesetSummaryAudit struct {
changeCount int64
completeChangeCount int64
partialChangeCount int64
unsupportedChangeCount int64
containsStateReset bool
containsIncompleteChanges bool
affectedSheets []sheetChangesetAffectedSheetAudit
}
type sheetChangesetAffectedSheetAudit struct {
sheetID string
sheetName string
ranges []string
}
type sheetChangesetAffectedSheetAccumulator struct {
sheetName string
ranges map[string]struct{}
}
func validateSheetChangesetAudit(request, object map[string]any) error {
requestStart := request["startRevision"].(int64)
responseStart, _ := sheetNonNegativeInteger(object["startRevision"])
responseEnd, _ := sheetNonNegativeInteger(object["endRevision"])
latest, _ := sheetNonNegativeInteger(object["latestRevision"])
if responseStart != requestStart {
return fmt.Errorf("$.startRevision=%d,与请求值 %d 不一致", responseStart, requestStart)
}
if requestEnd, exists := request["endRevision"]; exists {
if responseEnd != requestEnd.(int64) {
return fmt.Errorf("$.endRevision=%d,与请求值 %d 不一致", responseEnd, requestEnd)
}
} else if responseEnd != latest {
return fmt.Errorf("省略 endRevision 时 $.endRevision=%d,必须等于 $.latestRevision=%d", responseEnd, latest)
}
if responseEnd-responseStart > sheetChangesetMaxSpan {
return fmt.Errorf("响应区间超过 %d 个 revision", sheetChangesetMaxSpan)
}
changesets := object["changesets"].([]any)
wantCount := responseEnd - responseStart
if int64(len(changesets)) != wantCount {
return fmt.Errorf("$.changesets 数量为 %d,无法完整覆盖 (%d,%d]", len(changesets), responseStart, responseEnd)
}
for index, rawChangeset := range changesets {
changeset := rawChangeset.(map[string]any)
revision, err := sheetNonNegativeInteger(changeset["revision"])
if err != nil {
return fmt.Errorf("$.changesets[%d].revision %v", index, err)
}
expected := responseStart + int64(index) + 1
if revision != expected {
return fmt.Errorf("$.changesets[%d].revision=%d,期望连续 revision %d", index, revision, expected)
}
}
actualSummary, err := parseSheetChangesetSummaryAudit(object["summary"].(map[string]any))
if err != nil {
return err
}
expectedSummary := summarizeSheetChangesetsForAudit(changesets)
if !reflect.DeepEqual(actualSummary, expectedSummary) {
return fmt.Errorf("$.summary 与 $.changesets 复算结果不一致")
}
return nil
}
func parseSheetChangesetSummaryAudit(raw map[string]any) (sheetChangesetSummaryAudit, error) {
fields := []string{"changeCount", "completeChangeCount", "partialChangeCount", "unsupportedChangeCount"}
counts := make([]int64, len(fields))
for index, field := range fields {
value, err := sheetNonNegativeInteger(raw[field])
if err != nil {
return sheetChangesetSummaryAudit{}, fmt.Errorf("$.summary.%s %v", field, err)
}
counts[index] = value
}
result := sheetChangesetSummaryAudit{
changeCount: counts[0],
completeChangeCount: counts[1],
partialChangeCount: counts[2],
unsupportedChangeCount: counts[3],
containsStateReset: raw["containsStateReset"].(bool),
containsIncompleteChanges: raw["containsIncompleteChanges"].(bool),
}
for _, rawSheet := range raw["affectedSheets"].([]any) {
sheet := rawSheet.(map[string]any)
rawRanges := sheet["ranges"].([]any)
affected := sheetChangesetAffectedSheetAudit{
sheetID: sheet["sheetId"].(string),
ranges: make([]string, 0, len(rawRanges)),
}
affected.sheetName, _ = sheet["sheetName"].(string)
for _, rawRange := range rawRanges {
affected.ranges = append(affected.ranges, rawRange.(string))
}
result.affectedSheets = append(result.affectedSheets, affected)
}
return result, nil
}
func summarizeSheetChangesetsForAudit(changesets []any) sheetChangesetSummaryAudit {
result := sheetChangesetSummaryAudit{}
affected := map[string]*sheetChangesetAffectedSheetAccumulator{}
for _, rawChangeset := range changesets {
changeset := rawChangeset.(map[string]any)
result.containsStateReset = result.containsStateReset || changeset["eventType"] == "STATE_RESET"
result.containsIncompleteChanges = result.containsIncompleteChanges || changeset["detailsStatus"] != "COMPLETE"
for _, rawChange := range changeset["changes"].([]any) {
change := rawChange.(map[string]any)
result.changeCount++
switch change["detailsStatus"] {
case "COMPLETE":
result.completeChangeCount++
case "PARTIAL":
result.partialChangeCount++
}
if change["type"] == "UNSUPPORTED_CHANGE" {
result.unsupportedChangeCount++
}
for _, rawTarget := range change["targets"].([]any) {
target := rawTarget.(map[string]any)
if target["role"] == "SOURCE" {
continue
}
sheetID, _ := target["sheetId"].(string)
if strings.TrimSpace(sheetID) == "" {
continue
}
accumulator := affected[sheetID]
if accumulator == nil {
accumulator = &sheetChangesetAffectedSheetAccumulator{ranges: map[string]struct{}{}}
affected[sheetID] = accumulator
}
if sheetName, ok := target["sheetName"].(string); ok && strings.TrimSpace(sheetName) != "" {
accumulator.sheetName = sheetName
}
if a1Range, ok := target["a1Range"].(string); ok && strings.TrimSpace(a1Range) != "" {
accumulator.ranges[a1Range] = struct{}{}
}
}
}
}
sheetIDs := make([]string, 0, len(affected))
for sheetID := range affected {
sheetIDs = append(sheetIDs, sheetID)
}
sort.Strings(sheetIDs)
for _, sheetID := range sheetIDs {
accumulator := affected[sheetID]
ranges := make([]string, 0, len(accumulator.ranges))
for a1Range := range accumulator.ranges {
ranges = append(ranges, a1Range)
}
sort.Strings(ranges)
result.affectedSheets = append(result.affectedSheets, sheetChangesetAffectedSheetAudit{
sheetID: sheetID, sheetName: accumulator.sheetName, ranges: ranges,
})
}
return result
}
func sheetNonNegativeInteger(value any) (int64, error) {
number, ok := value.(json.Number)
if !ok {
return 0, fmt.Errorf("必须是非负整数")
}
parsed, err := strconv.ParseInt(number.String(), 10, 64)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("必须是非负整数")
}
return parsed, nil
}
func sheetRevisionNumberArg(raw string) (any, error) {
return strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
}
func validateSheetChangesetRange(cmd *cobra.Command, _ []string) error {
start, err := parseSheetRevisionFlag(cmd, "start-revision")
if err != nil {
return err
}
if start < 0 {
return apperrors.NewValidation("--start-revision 必须是非负整数")
}
endRaw, _ := cmd.Flags().GetString("end-revision")
if strings.TrimSpace(endRaw) == "" {
return nil
}
end, err := parseSheetRevisionFlag(cmd, "end-revision")
if err != nil {
return err
}
if end < 0 {
return apperrors.NewValidation("--end-revision 必须是非负整数")
}
if end < start {
return apperrors.NewValidation("--end-revision 必须大于或等于 --start-revision")
}
if end-start > sheetChangesetMaxSpan {
return apperrors.NewValidation(fmt.Sprintf(
"单次最多查询 %d 个 revision;请把 --end-revision 调整为不大于 %d",
sheetChangesetMaxSpan, start+sheetChangesetMaxSpan,
))
}
return nil
}
func parseSheetRevisionFlag(cmd *cobra.Command, name string) (int64, error) {
raw, _ := cmd.Flags().GetString(name)
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 64 位整数", name))
}
return value, nil
}
File diff suppressed because it is too large Load Diff
+13 -8
View File
@@ -111,9 +111,14 @@ func newSheetVersionCmd() *cobra.Command {
versionListCmd.Flags().String("cursor", "", "分页游标")
versionRevertCmd := &cobra.Command{
Use: "revert",
Short: "[危险] 回滚表格到指定版本",
Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`,
Use: "revert",
Short: "[危险] 回滚表格到指定历史版本或 revision",
Long: `将在线表格恢复到指定历史版本或精确 revision。
通常应从 version list 选择已保存的历史版本。用户明确要求恢复到某个精确 revision 时,
也可传入已从同一工作簿真实查询结果确认的 revision,即使它不在版本列表中。未列入
版本列表的 revision 只有在服务端仍可恢复时才能成功;禁止猜测 revision。`,
Example: ` dws sheet version revert --node SHEET_ID --version 3`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -142,22 +147,22 @@ func newSheetVersionCmd() *cobra.Command {
CLIPath: "sheet version revert",
PrimaryCLIPath: "sheet version revert",
},
Description: "回滚表格到指定历史版本",
Description: "回滚表格到指定历史版本或已确认的精确 revision",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "回滚表格到指定历史版本",
UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格"},
AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert"},
AgentSummary: "回滚表格到指定历史版本或已确认的精确 revision",
UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格,或明确要求恢复到同一工作簿中已确认的 revision"},
AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert;目标 revision 未经同一工作簿的真实查询结果确认时不要猜测"},
Examples: []string{"dws sheet version revert --node <SHEET_ID> --version 3 --format json"},
},
},
})
versionRevertCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)")
versionRevertCmd.Flags().Int("version", 0, "目标历史版本或已确认 revision (必填,通常从 version list 获取)")
for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} {
c.Flags().String("url", "", "")
+78
View File
@@ -0,0 +1,78 @@
// 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 (
"bytes"
"reflect"
"strings"
"testing"
)
func TestSheetVersionRevertHelpDocumentsConfirmedRevisionTargets(t *testing.T) {
command := newSheetVersionCmd()
var output bytes.Buffer
command.SetOut(&output)
command.SetErr(&output)
command.SetArgs([]string{"revert", "--help"})
if err := command.Execute(); err != nil {
t.Fatalf("sheet version revert --help returned error: %v", err)
}
help := output.String()
for _, expected := range []string{
"通常应从 version list 选择已保存的历史版本",
"已从同一工作簿真实查询结果确认的 revision",
"禁止猜测 revision",
"目标历史版本或已确认 revision",
} {
if !strings.Contains(help, expected) {
t.Fatalf("sheet version revert help missing %q:\n%s", expected, help)
}
}
if strings.Contains(help, "--yes") {
t.Fatalf("sheet version revert help must not publish a confirmation-bypass example:\n%s", help)
}
}
func TestSheetVersionRevertRequiresConfirmationAndCallsExactToolWhenConfirmed(t *testing.T) {
args := []string{"version", "revert", "--node", "node-1", "--version", "37"}
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("expected 0 MCP calls before confirmation, got %d: %+v", len(caller.calls), caller.calls)
}
caller = &guardedMutationCaller{}
confirmedArgs := append(append([]string(nil), args...), "--yes")
if err := executeGuardedMutationCommand(t, caller, newSheetCommand, confirmedArgs...); err != nil {
t.Fatalf("sheet version revert after confirmation returned error: %v", err)
}
want := guardedMutationCall{
productID: "doc",
toolName: "revert_doc_version",
args: map[string]any{
"nodeId": "node-1",
"version": 37,
},
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call after confirmation, got %d: %+v", len(caller.calls), caller.calls)
}
if !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool call = %#v, want %#v", caller.calls[0], want)
}
}
+1
View File
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
"已取消操作": "Operation cancelled",
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "The current identity's refresh_token is invalid; login state was recovered from the organization mirror token.",
"待办": "todo",
"待办任务 ID (必填)": "Todo task ID (required)",
"待办任务管理": "Todo task management",
+1
View File
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
"已取消操作": "已取消操作",
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态",
"待办": "待办",
"待办任务 ID (必填)": "待办任务 ID (必填)",
"待办任务管理": "待办任务管理",
@@ -479,8 +479,8 @@ func evaluateCommandMigrationLifecycle(
}
}
if allAfter {
if exists {
return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey())
if exists && proposed.State != CommandMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed command migration %s back to pending", approved.displayKey())
}
continue
}
@@ -383,9 +383,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
{"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"},
{"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"},
{"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"},
{"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"},
{"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"},
{"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"},
{"inert consumed back to pending", after, map[string]Snapshot{"main": after, "stable": after}, consumed, pending, "back to pending"},
{"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"},
{"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"},
} {
@@ -402,6 +402,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 {
t.Fatalf("cleaned stale receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed); err != nil || len(got) != 0 {
t.Fatalf("retained inert receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 {
t.Fatalf("candidate pending plan=%#v, %v", got, err)
}
+8 -4
View File
@@ -889,7 +889,7 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceipt
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsRetentionOrCleanupAfterAllReferencesCatchUp(t *testing.T) {
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
references := map[string]Snapshot{
@@ -897,11 +897,15 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAl
"stable": after,
}
if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed)
if err != nil {
t.Fatalf("inert consumed receipt was rejected after every reference caught up: %v", err)
}
if !report.Compatible {
t.Fatalf("inert consumed receipt changed compatibility: %#v", report.Comparisons)
}
report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
report, err = CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
if err != nil {
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
}
+127 -20
View File
@@ -17,6 +17,9 @@ const FlagMigrationManifestVersion = 1
const (
FlagMigrationPending = "pending"
FlagMigrationConsumed = "consumed"
FlagMigrationRename = "flag_rename"
FlagMigrationRequirednessChange = "requiredness_change"
)
type FlagMigrationManifest struct {
@@ -25,11 +28,13 @@ type FlagMigrationManifest struct {
}
type FlagMigration struct {
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
State string `json:"state"`
Reason string `json:"reason"`
Kind string `json:"kind,omitempty"`
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
Flag *FlagMigrationSide `json:"flag,omitempty"`
State string `json:"state"`
Reason string `json:"reason"`
}
type FlagMigrationSide struct {
@@ -287,6 +292,23 @@ func (m FlagMigrationManifest) Validate() error {
}
legacyTargets[legacyKey] = migration.Canonical.Name
}
for _, requiredness := range m.Migrations {
if requiredness.EffectiveKind() != FlagMigrationRequirednessChange {
continue
}
for _, rename := range m.Migrations {
if rename.EffectiveKind() != FlagMigrationRename || rename.Command != requiredness.Command {
continue
}
if rename.Legacy.Name == requiredness.Flag.Name || rename.Canonical.Name == requiredness.Flag.Name {
return fmt.Errorf(
"flag requiredness migration %s overlaps rename migration %s",
requiredness.displayKey(),
rename.displayKey(),
)
}
}
}
return nil
}
@@ -294,15 +316,6 @@ func (m FlagMigration) validate() error {
if !isExactCommandPath(m.Command) {
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if strings.TrimSpace(m.Reason) == "" {
return fmt.Errorf("migration must include a non-empty reason")
}
@@ -312,6 +325,30 @@ func (m FlagMigration) validate() error {
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
switch m.EffectiveKind() {
case FlagMigrationRename:
return m.validateRename()
case FlagMigrationRequirednessChange:
return m.validateRequirednessChange()
default:
return fmt.Errorf("invalid kind %q", m.Kind)
}
}
func (m FlagMigration) validateRename() error {
if m.Flag != nil {
return fmt.Errorf("flag rename must not declare flag requiredness fields")
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if err := m.Legacy.Before.validate("legacy before"); err != nil {
return err
}
@@ -392,6 +429,45 @@ func (m FlagMigration) validate() error {
return nil
}
func (m FlagMigration) validateRequirednessChange() error {
if m.Legacy != (FlagMigrationSide{}) || m.Canonical != (FlagMigrationSide{}) {
return fmt.Errorf("flag requiredness migration must not declare legacy or canonical rename fields")
}
if m.Flag == nil || !isExactFlagName(m.Flag.Name) {
return fmt.Errorf("flag name must be an exact flag: %q", flagMigrationSideName(m.Flag))
}
if err := m.Flag.Before.validate("flag before"); err != nil {
return err
}
if err := m.Flag.After.validate("flag after"); err != nil {
return err
}
if !m.Flag.Before.Present || !m.Flag.After.Present || m.Flag.Before.Required || !m.Flag.After.Required {
return fmt.Errorf("flag requiredness migration must change exactly from optional to required")
}
if m.Flag.Before.Hidden || m.Flag.After.Hidden {
return fmt.Errorf("flag requiredness migration flag must stay visible")
}
if m.Flag.Before.AliasOf != "" || m.Flag.After.AliasOf != "" {
return fmt.Errorf("flag requiredness migration must not declare alias_of")
}
before := m.Flag.Before
after := m.Flag.After
before.Required = false
after.Required = false
if before != after {
return fmt.Errorf("flag requiredness migration must preserve every flag attribute except requiredness")
}
return nil
}
func flagMigrationSideName(side *FlagMigrationSide) string {
if side == nil {
return ""
}
return side.Name
}
func (s FlagMigrationState) validate(label string) error {
if !s.Present {
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
@@ -409,7 +485,19 @@ func (s FlagMigrationState) validate(label string) error {
}
func (m FlagMigration) key() string {
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
if m.EffectiveKind() == FlagMigrationRequirednessChange {
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + flagMigrationSideName(m.Flag)
}
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
}
// EffectiveKind keeps manifests written before kinds were introduced valid.
// An omitted kind is the original flag rename primitive.
func (m FlagMigration) EffectiveKind() string {
if m.Kind == "" {
return FlagMigrationRename
}
return m.Kind
}
func isExactCommandPath(path string) bool {
@@ -428,9 +516,9 @@ func isExactFlagName(name string) bool {
}
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
// records participate in the lifecycle check, but never authorize their own
// interface change.
// then consumes only exact, merge-base-owned flag rename or requiredness
// migrations. Candidate-owned records participate in the lifecycle check, but
// never authorize their own interface change.
func CompareAllWithFlagMigrations(
current Snapshot,
references map[string]Snapshot,
@@ -579,8 +667,8 @@ func evaluateFlagMigrationLifecycle(
}
}
if allReferencesAfter {
if exists {
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
if exists && proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
}
continue
}
@@ -640,6 +728,16 @@ func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMig
if !exists {
return flagMigrationPartial
}
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
state := flagMigrationStateForCommand(command, flagMigrationSideName(migration.Flag))
if state == migration.Flag.Before {
return flagMigrationBefore
}
if state == migration.Flag.After {
return flagMigrationAfter
}
return flagMigrationPartial
}
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
@@ -694,6 +792,12 @@ func flagMigrationAuthorizesChange(
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
continue
}
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
if change.Flag == migration.Flag.Name && change.Kind == "flag_became_required" {
return true
}
continue
}
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
return true
}
@@ -717,5 +821,8 @@ func flagMigrationAuthorizesChange(
}
func (m FlagMigration) displayKey() string {
if m.EffectiveKind() == FlagMigrationRequirednessChange {
return fmt.Sprintf("%q --%s optional -> required", m.Command, flagMigrationSideName(m.Flag))
}
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
}
@@ -602,12 +602,12 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *tes
wantErr: "back to pending",
},
{
name: "consumed receipt becomes stale",
name: "inert consumed receipt cannot revert to pending",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: consumed,
candidate: consumed,
wantErr: "is stale after all references reached the after state",
candidate: pending,
wantErr: "back to pending",
},
{
name: "candidate-added receipt starts pending",
@@ -857,15 +857,15 @@ func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *te
}
})
t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(
t.Run("consumed receipt becomes inert after every reference converges", func(t *testing.T) {
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after, "stable": after},
consumed,
consumed,
)
if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
t.Fatalf("stale optional migration error = %v", err)
if err != nil || !report.Compatible {
t.Fatalf("inert optional migration = (%#v, %v), want compatible", report, err)
}
})
}
@@ -38,6 +38,23 @@ const validFlagMigrationManifestJSON = `{
]
}`
const validRequirednessMigrationManifestJSON = `{
"version": 1,
"migrations": [
{
"kind": "requiredness_change",
"command": "dws report entry submit",
"flag": {
"name": "to-user-ids",
"before": {"present": true, "type": "string", "scope": "local"},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reject report submissions that have no visible recipient."
}
]
}`
func optionalFlagMigrationManifestJSON() string {
manifest := strings.Replace(
validFlagMigrationManifestJSON,
@@ -153,6 +170,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
}
if _, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)); err != nil {
t.Fatalf("ReadFlagMigrationManifest(requiredness change) error = %v", err)
}
tests := []struct {
name string
@@ -246,6 +266,229 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
}
}
func TestCrossPlatformCoverageRequirednessMigrationRejectsInexactContracts(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "unknown kind",
input: strings.Replace(validRequirednessMigrationManifestJSON, `"requiredness_change"`, `"anything"`, 1),
wantErr: "invalid kind",
},
{
name: "missing flag name",
input: strings.Replace(validRequirednessMigrationManifestJSON, `"to-user-ids"`, `""`, 1),
wantErr: "exact flag",
},
{
name: "invalid before state",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"before": {"present": true, "type": "string", "scope": "local"}`,
`"before": {"present": true, "type": "string", "scope": "anything"}`,
1,
),
wantErr: "flag before present state has invalid scope",
},
{
name: "invalid after state",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "scope": "anything"}`,
1,
),
wantErr: "flag after present state has invalid scope",
},
{
name: "before already required",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"before": {"present": true, "type": "string", "scope": "local"}`,
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
1,
),
wantErr: "optional to required",
},
{
name: "after remains optional",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "scope": "local"}`,
1,
),
wantErr: "optional to required",
},
{
name: "type drift",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "stringSlice", "required": true, "scope": "local"}`,
1,
),
wantErr: "must preserve every flag attribute except requiredness",
},
{
name: "visibility drift",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`,
1,
),
wantErr: "must stay visible",
},
{
name: "alias relation",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "scope": "local", "alias_of": "other"}`,
1,
),
wantErr: "must not declare alias_of",
},
{
name: "rename fields cannot be combined",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"flag": {`,
`"legacy": {"name":"old","before":{"present":false},"after":{"present":false}}, "flag": {`,
1,
),
wantErr: "must not declare legacy or canonical",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
missingFlag := FlagMigration{
Kind: FlagMigrationRequirednessChange,
Command: "dws report entry submit",
State: FlagMigrationPending,
Reason: "Missing exact flag state.",
}
if err := missingFlag.validate(); err == nil || !strings.Contains(err.Error(), "exact flag") {
t.Fatalf("missing requiredness flag error = %v", err)
}
rename, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
rename.Migrations[0].Kind = FlagMigrationRename
rename.Migrations[0].Flag = &FlagMigrationSide{
Name: "unrelated",
Before: FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
}
if err := rename.Validate(); err == nil || !strings.Contains(err.Error(), "must not declare flag requiredness fields") {
t.Fatalf("rename with requiredness fields error = %v", err)
}
}
func TestCrossPlatformCoverageRequirednessMigrationLifecycleAndAuthorization(t *testing.T) {
pending, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
migration := pending.Migrations[0]
before := requirednessMigrationSnapshot(migration, false)
after := requirednessMigrationSnapshot(migration, true)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_required", migration.Command, migration.Flag.Name) {
t.Fatalf("fixture did not produce flag_became_required: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("governed requiredness change = (%#v, %v), want compatible", report, err)
}
partial := after
partial.Commands = append([]Command(nil), after.Commands...)
partial.Commands[len(partial.Commands)-1].LocalFlags = append(
[]Flag(nil),
after.Commands[len(after.Commands)-1].LocalFlags...,
)
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Type = "stringSlice"
if phase := matchFlagMigrationPhase(partial, migration); phase != flagMigrationPartial {
t.Fatalf("drifted requiredness migration phase = %s, want partial", phase)
}
beforeWithUnrelated := before
beforeWithUnrelated.Commands = append([]Command(nil), before.Commands...)
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags = append(
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags,
Flag{Name: "unrelated", Type: "string"},
)
afterWithUnrelated := after
afterWithUnrelated.Commands = append([]Command(nil), after.Commands...)
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags = append(
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags,
Flag{Name: "unrelated", Type: "string", Required: true},
)
report, err = CompareAllWithFlagMigrations(
afterWithUnrelated,
map[string]Snapshot{"merge-base": beforeWithUnrelated, "stable": beforeWithUnrelated},
pending,
consumed,
)
if err != nil || report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_became_required", migration.Command, "unrelated") {
t.Fatalf("unrelated requiredness change was hidden: report=%#v err=%v", report, err)
}
empty := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
if _, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
t.Fatalf("candidate self-authorization error = %v", err)
}
}
func requirednessMigrationSnapshot(migration FlagMigration, after bool) Snapshot {
state := migration.Flag.Before
if after {
state = migration.Flag.After
}
flag := Flag{
Name: migration.Flag.Name,
Type: state.Type,
Required: state.Required,
Hidden: state.Hidden,
Shorthand: state.Shorthand,
NoOpt: state.NoOpt,
}
command := testCommandWithFlagScopes(migration.Command, nil, nil)
if state.Scope == "inherited" {
command.InheritedFlags = []Flag{flag}
} else {
command.LocalFlags = []Flag{flag}
}
return testSnapshot(testCommand("dws"), command)
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
@@ -282,6 +525,22 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
if err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing JSON error = %v", err)
}
requiredness, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
overlap := manifest
overlap.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
rename := overlap.Migrations[0]
rename.Command = requiredness.Migrations[0].Command
rename.Canonical.Name = requiredness.Migrations[0].Flag.Name
rename.Legacy.After.AliasOf = rename.Canonical.Name
overlap.Migrations[0] = rename
overlap.Migrations = append(overlap.Migrations, requiredness.Migrations[0])
if err := overlap.Validate(); err == nil || !strings.Contains(err.Error(), "overlaps rename migration") {
t.Fatalf("requiredness and rename overlap error = %v", err)
}
}
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
@@ -0,0 +1,101 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_ding.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "ding" {
registered[item.Command] = item
}
}
if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 {
t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts))
}
wantCompatibilityVisible := map[string]bool{
"+list": true,
"+recall-personal": true,
"+send-personal": true,
}
wantCompatibilityAvailability := map[string]shortcut.Availability{
"+list": shortcut.AvailabilityUnavailable,
"+recall-personal": shortcut.AvailabilityAvailable,
"+send-personal": shortcut.AvailabilityAvailable,
}
public, unavailable, compatibilityVisible := 0, 0, 0
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
availability := record.Availability
if availability == "" {
availability = source.Availability
}
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s semantic review facts drifted", command)
}
if item.Risk != record.Risk || item.Availability != availability {
t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
}
if record.Public {
public++
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("ding", command) {
t.Errorf("%s public availability/visibility drift", command)
}
if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
}
} else {
if record.CompatibilityVisible {
compatibilityVisible++
if item.Hidden || !item.CompatibilityVisible || availability != wantCompatibilityAvailability[command] || shortcut.InPublicCatalog("ding", command) {
t.Errorf("%s compatibility-visible boundary drift", command)
}
} else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) {
t.Errorf("%s nonpublic shortcut visibility drift", command)
}
if availability == shortcut.AvailabilityUnavailable {
unavailable++
}
}
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 1 || unavailable != 2 || compatibilityVisible != len(wantCompatibilityVisible) {
t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
}
}
@@ -0,0 +1,102 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_oa.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "oa" {
registered[item.Command] = item
}
}
if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 {
t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts))
}
wantCompatibilityVisible := map[string]bool{
"+list-cc": true,
"+list-executed": true,
"+list-forms": true,
"+list-pending": true,
"+list-submitted": true,
"+my-initiated": true,
}
public, unavailable, compatibilityVisible := 0, 0, 0
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
availability := record.Availability
if availability == "" {
availability = source.Availability
}
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s semantic review facts drifted", command)
}
if item.Risk != record.Risk || item.Availability != availability {
t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
}
if item.Contract.Interface == nil || item.Contract.Interface.Availability != string(availability) {
t.Errorf("%s final interface availability=%v, want %q", command, item.Contract.Interface, availability)
}
if record.Public {
public++
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) {
t.Errorf("%s public availability/visibility drift", command)
}
if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
}
} else {
if record.CompatibilityVisible {
compatibilityVisible++
if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityAvailable || shortcut.InPublicCatalog("oa", command) {
t.Errorf("%s compatibility-visible boundary drift", command)
}
} else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) {
t.Errorf("%s nonpublic shortcut visibility drift", command)
}
if availability == shortcut.AvailabilityUnavailable {
unavailable++
}
}
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 1 || unavailable != 0 || compatibilityVisible != len(wantCompatibilityVisible) {
t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/0/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
}
}
@@ -0,0 +1,83 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_report.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "report" {
registered[item.Command] = item
}
}
if source.Service != "report" || len(registered) != 4 || len(source.Shortcuts) != 4 {
t.Fatalf("service/registered/catalog=%s/%d/%d, want report/4/4", source.Service, len(registered), len(source.Shortcuts))
}
public, unavailable := 0, 0
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
availability := record.Availability
if availability == "" {
availability = source.Availability
}
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s semantic review facts drifted", command)
}
if item.Risk != record.Risk || item.Availability != availability {
t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
}
if record.Public {
public++
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("report", command) {
t.Errorf("%s public availability/visibility drift", command)
}
if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
}
} else {
if !item.Hidden || shortcut.InPublicCatalog("report", command) {
t.Errorf("%s nonpublic shortcut is visible", command)
}
if availability == shortcut.AvailabilityUnavailable {
unavailable++
}
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 4 || unavailable != 0 {
t.Fatalf("public/unavailable=%d/%d, want 4/0", public, unavailable)
}
}
@@ -72,14 +72,24 @@ func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPaginatio
if err != nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err)
}
var serviceEmptySentinel map[string]any
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"attendees":null,"categories":null,"meetingRooms":null,"reminders":null}],"hasMore":false}}`), &serviceEmptySentinel); err != nil {
t.Fatal(err)
}
events, page, err = eventListProject(serviceEmptySentinel)
if err != nil || events == nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("service empty sentinel: 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}}`,
"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}}`,
"unknown null item": `{"success":true,"result":{"events":[{"summary":null}],"hasMore":false}}`,
"sentinel with cursor": `{"success":true,"result":{"events":[{"attendees":null}],"hasMore":false,"nextCursor":"unexpected"}}`,
"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
+4
View File
@@ -16,6 +16,7 @@ import (
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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
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."
@@ -205,6 +206,9 @@ func requireCalendarCollection(data map[string]any, operation string, keys ...st
if !ok {
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
if key == "events" {
items, _ = calendarcompat.NormalizeTerminalEmptyEvents(items, container)
}
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
return nil, nil, err
}
@@ -0,0 +1,48 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package calendarcompat contains narrow compatibility adapters for observed
// Calendar wire shapes shared by the calendar and smart Shortcut packages.
package calendarcompat
// NormalizeTerminalEmptyEvents recognizes the Calendar service's legacy empty
// page sentinel. Some deployments encode an exhausted empty page as one object
// whose collection fields are all null instead of events:[]. Only that exact
// terminal shape is normalized; arbitrary id-less or mixed event rows remain
// available to the strict caller validation and must fail closed.
func NormalizeTerminalEmptyEvents(items []any, container map[string]any) ([]any, bool) {
if len(items) != 1 || container == nil {
return items, false
}
hasMore, ok := container["hasMore"].(bool)
if !ok || hasMore || hasNonEmptyCursor(container) {
return items, false
}
placeholder, ok := items[0].(map[string]any)
knownNullFields := map[string]struct{}{
"attendees": {},
"categories": {},
"meetingRooms": {},
"reminders": {},
}
if !ok || len(placeholder) != len(knownNullFields) {
return items, false
}
for key, value := range placeholder {
if _, known := knownNullFields[key]; !known || value != nil {
return items, false
}
}
return []any{}, true
}
func hasNonEmptyCursor(container map[string]any) bool {
for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
value, present := container[key]
if !present || value == nil || value == "" {
continue
}
return true
}
return false
}
@@ -0,0 +1,44 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package calendarcompat
import "testing"
func TestCrossPlatformCoverageNormalizeTerminalEmptyEvents(t *testing.T) {
sentinel := []any{map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
}}
if got, ok := NormalizeTerminalEmptyEvents(sentinel, map[string]any{"hasMore": false}); !ok || got == nil || len(got) != 0 {
t.Fatalf("terminal sentinel = %#v, %v; want explicit empty slice", got, ok)
}
for name, items := range map[string][]any{
"empty object": {map[string]any{}},
"missing fields": {map[string]any{"attendees": nil}},
"unknown field": {map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "summary": nil,
}},
"non-null field": {map[string]any{
"attendees": []any{}, "categories": nil, "meetingRooms": nil, "reminders": nil,
}},
"mixed rows": {sentinel[0], map[string]any{"id": "event-1"}},
} {
t.Run(name, func(t *testing.T) {
if _, ok := NormalizeTerminalEmptyEvents(items, map[string]any{"hasMore": false}); ok {
t.Fatalf("invalid sentinel accepted: %#v", items)
}
})
}
for name, container := range map[string]map[string]any{
"missing terminal evidence": {},
"more pages": {"hasMore": true},
"cursor": {"hasMore": false, "nextCursor": "next"},
} {
t.Run(name, func(t *testing.T) {
if _, ok := NormalizeTerminalEmptyEvents(sentinel, container); ok {
t.Fatalf("non-terminal sentinel accepted with %#v", container)
}
})
}
}
+316
View File
@@ -0,0 +1,316 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package ding
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const (
dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection."
dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete."
dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts."
)
type dingPageEvidence struct {
HasMore bool
Next string
}
func dingReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func dingWriteSafety(destructive bool) contract.SafetySpec {
effect, risk := "write", "medium"
if destructive {
effect, risk = "destructive", "high"
}
return contract.SafetySpec{Effect: effect, Risk: risk, Confirmation: "user_required", Idempotency: "unknown"}
}
func dingListResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的 DING 消息页","properties":{"count":{"type":"integer","description":"当前页有效 DING 消息数量"},"messages":{"type":"array","description":"严格验证并投影后的 DING 消息","items":{"type":"object","description":"一条具有稳定 openDingId 的 DING 消息","properties":{"openDingId":{"type":"string","description":"稳定 DING 消息 ID"},"content":{"type":"string","description":"DING 消息内容"},"sourceMessageId":{"type":"string","description":"作为 DING 来源的聊天消息 ID"},"sendTime":{"type":"string","description":"服务端返回的发送时间"},"senderNick":{"type":"string","description":"发送人显示名"}},"required":["openDingId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"当前页是否具有服务端分页终态证据"}},"required":["count","messages","complete"],"additionalProperties":false}`),
SensitivePaths: []string{"messages.content", "messages.senderNick"},
}
}
func dingReceiverResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"指定 DING 的严格接收状态","properties":{"count":{"type":"integer","description":"经验证的接收状态数量"},"receivers":{"type":"array","description":"身份匹配的 DING 接收状态","items":{"type":"object","description":"一名接收人的 DING 确认状态","properties":{"openDingId":{"type":"string","description":"与请求一致的稳定 DING ID"},"confirmedStatus":{"type":"integer","description":"服务端 DING 确认状态码"},"receiverNick":{"type":"string","description":"接收人显示名"}},"required":["openDingId","confirmedStatus","receiverNick"],"additionalProperties":false}}},"required":["count","receivers"],"additionalProperties":false}`),
SensitivePaths: []string{"receivers.receiverNick"},
}
}
func dingPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func dingContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "ding " + command
availability, reason := contract.InterfaceAvailable, dingCompositeReason
if !available {
availability, reason = contract.InterfaceUnavailable, dingWriteUnavailableReason
}
return corecmd.ContractDecl{
Description: description,
Result: result,
Pagination: pagination,
Parameters: params,
Identity: contract.ToolIdentitySpec{
ProductID: "ding", Name: name, CanonicalPath: "ding." + name,
CLIPath: cliPath, PrimaryCLIPath: cliPath,
},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{"普通聊天消息使用 chat;写能力在缺少可逆真实 fixture、精确读回或清理证据时不要执行"},
Examples: examples,
},
}
}
func dingResponseError(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 dingRequireResult(data map[string]any, operation string) (map[string]any, error) {
if len(data) == 0 {
return nil, dingResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 DING 调用成功或结果确实为空")
}
success, present := data["success"]
if !present {
return nil, dingResponseError(operation, "missing_success", "DING 响应缺少 success 业务状态")
}
succeeded, ok := success.(bool)
if !ok {
return nil, dingResponseError(operation, "invalid_success_type", fmt.Sprintf("DING 响应 success 应为布尔值,实际为 %T", success))
}
if !succeeded {
message := "DING 服务明确返回失败"
for _, key := range []string{"errorMsg", "errorMessage", "message"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
message = strings.TrimSpace(value)
break
}
}
return nil, dingResponseError(operation, "remote_failure", message)
}
result, ok := data["result"].(map[string]any)
if !ok || result == nil {
return nil, dingResponseError(operation, "missing_result", "DING 成功响应缺少 result 对象")
}
return result, nil
}
func dingRequireObjectCollection(result map[string]any, operation, field string) ([]map[string]any, error) {
raw, present := result[field]
if !present {
return nil, dingResponseError(operation, "missing_collection", fmt.Sprintf("DING 成功响应缺少 result.%s 数组", field))
}
values, ok := raw.([]any)
if !ok {
return nil, dingResponseError(operation, "malformed_collection", fmt.Sprintf("DING result.%s 应为数组,实际为 %T", field, raw))
}
items := make([]map[string]any, 0, len(values))
for index, value := range values {
item, ok := value.(map[string]any)
if !ok || len(item) == 0 {
return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING result.%s[%d] 不是非空对象", field, index))
}
items = append(items, item)
}
return items, nil
}
func dingRequiredString(item map[string]any, operation, field string, index int) (string, error) {
value, ok := item[field].(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return "", dingResponseError(operation, "missing_item_identity", fmt.Sprintf("DING 结果第 %d 项缺少非空 %s", index, field))
}
return value, nil
}
func dingOptionalString(item map[string]any, field string) (string, error) {
value, present := item[field]
if !present || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", fmt.Errorf("字段 %s 应为字符串,实际为 %T", field, value)
}
return strings.TrimSpace(text), nil
}
func dingInteger(value any) (int64, bool) {
switch typed := value.(type) {
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 {
return 0, false
}
return int64(typed), true
case int:
return int64(typed), true
case int64:
return typed, true
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
default:
return 0, false
}
}
func dingProjectMessages(data map[string]any, operation string) ([]map[string]any, dingPageEvidence, error) {
result, err := dingRequireResult(data, operation)
if err != nil {
return nil, dingPageEvidence{}, err
}
items, err := dingRequireObjectCollection(result, operation, "dingMessages")
if err != nil {
return nil, dingPageEvidence{}, err
}
messages := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id, identityErr := dingRequiredString(item, operation, "openDingId", index)
if identityErr != nil {
return nil, dingPageEvidence{}, identityErr
}
if _, duplicate := seen[id]; duplicate {
return nil, dingPageEvidence{}, dingResponseError(operation, "duplicate_item_identity", "当前 DING 页包含重复 openDingId")
}
seen[id] = struct{}{}
projected := map[string]any{"openDingId": id}
for source, target := range map[string]string{
"dingContent": "content", "dingSourceOpenMessageId": "sourceMessageId",
"sendTime": "sendTime", "senderNick": "senderNick",
} {
value, valueErr := dingOptionalString(item, source)
if valueErr != nil {
return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 结果第 %d 项%s", index, valueErr.Error()))
}
if value != "" {
projected[target] = value
}
}
messages = append(messages, projected)
}
hasMoreValue, present := result["hasMore"]
if !present {
return nil, dingPageEvidence{}, dingResponseError(operation, "missing_pagination", "DING 列表缺少 result.hasMore,不能宣称当前页完整")
}
hasMore, ok := hasMoreValue.(bool)
if !ok {
return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_pagination", "DING result.hasMore 应为布尔值")
}
page := dingPageEvidence{HasMore: hasMore}
if !hasMore {
if next, exists := result["nextCursor"]; exists && next != nil {
if numeric, valid := dingInteger(next); !valid || numeric != 0 {
return nil, dingPageEvidence{}, dingResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 仍表示后续页")
}
}
return messages, page, nil
}
if len(messages) == 0 {
return nil, dingPageEvidence{}, dingResponseError(operation, "empty_page_with_continuation", "DING 空页仍声明 hasMore=true,无法证明游标可安全推进")
}
next, valid := dingInteger(result["nextCursor"])
if !valid || next <= 0 {
return nil, dingPageEvidence{}, dingResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 nextCursor")
}
page.Next = strconv.FormatInt(next, 10)
return messages, page, nil
}
func dingProjectReceivers(data map[string]any, operation, expectedID string) ([]map[string]any, error) {
result, err := dingRequireResult(data, operation)
if err != nil {
return nil, err
}
items, err := dingRequireObjectCollection(result, operation, "receivers")
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, dingResponseError(operation, "empty_receiver_status", "DING 接收状态显式为空,无法证明目标 DING 的接收状态")
}
receivers := make([]map[string]any, 0, len(items))
for index, item := range items {
id, identityErr := dingRequiredString(item, operation, "openDingId", index)
if identityErr != nil {
return nil, identityErr
}
if id != expectedID {
return nil, dingResponseError(operation, "identity_mismatch", "DING 接收状态的 openDingId 与请求目标不一致")
}
status, ok := dingInteger(item["confirmedStatus"])
if !ok {
return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 接收状态第 %d 项 confirmedStatus 不是整数", index))
}
nick, nickErr := dingRequiredString(item, operation, "receiverNick", index)
if nickErr != nil {
return nil, nickErr
}
receivers = append(receivers, map[string]any{"openDingId": id, "confirmedStatus": status, "receiverNick": nick})
}
return receivers, nil
}
func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page dingPageEvidence) error {
payload := map[string]any{"count": len(messages), "messages": messages, "complete": !page.HasMore}
if !output.UsesUnifiedResult(rt.Command()) {
if page.HasMore {
payload["nextCursor"] = page.Next
}
return rt.Output(payload)
}
pagination, err := output.NewPagination(!page.HasMore, page.Next)
if err != nil {
return dingResponseError("im/list_ding_messages", "invalid_pagination", err.Error())
}
meta := &output.Meta{Count: output.NewCount(len(messages)), Pagination: pagination}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
}
func dingUnavailable(operation string) error {
return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行",
apperrors.WithOperation(operation),
apperrors.WithOrigin("shortcut_registry"),
apperrors.WithFailureStage("capability_gate"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithReason("write_fixture_unavailable"),
)
}
+154 -175
View File
@@ -1,226 +1,205 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Licensed under the Apache License, Version 2.0
// Package ding holds the built-in DING (钉) shortcuts. Tool names and params are
// copied verbatim from internal/helpers/ding.go. Some tools route to the "im"
// MCP server (helper uses callMCPToolOnServer("im", ...)), reflected in Product.
// Package ding registers strict declarative shortcuts for DingTalk DING.
package ding
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var List = shortcut.Shortcut{
Service: "ding", Command: "+list", Product: "im",
Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "ding",
Name: "shortcut_list",
CanonicalPath: "ding.shortcut_list",
CLIPath: "ding +list",
PrimaryCLIPath: "ding +list",
},
Description: "查询 DING 消息列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询 DING 消息列表",
UseWhen: []string{"当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +list"},
},
},
Description: "查询 DING 消息列表",
Intent: "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。",
Risk: shortcut.RiskRead,
Safety: dingReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: dingContract(
"+list", "查询 DING 消息列表",
"需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。",
true, dingListResult(), dingPagination(),
[]contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "type", Property: "type"}},
"dws ding +list --type ALL", "dws ding +list --type DELETED",
),
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"},
{Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "类型 (可选,默认 ALL)"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"},
{Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型:ALL、UNREAD、SEND、NEW_COMMENT 或 DELETED"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}},
Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("cursor") < 0 {
return apperrors.NewValidation("--cursor 不能小于 0")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
// type 是服务端必填,空值会报错;不传或传空时兜底为 ALL(对齐 helper)。
msgType := rt.Str("type")
if msgType == "" {
msgType = "ALL"
}
params := map[string]any{"type": msgType}
params := map[string]any{"type": rt.Str("type")}
if rt.Changed("cursor") {
params["cursor"] = rt.Int("cursor")
}
return rt.CallMCP("list_ding_messages", params)
data, err := rt.CallMCPData("im", "list_ding_messages", params)
if err != nil {
return err
}
messages, page, err := dingProjectMessages(data, "im/list_ding_messages")
if err != nil {
return err
}
if page.HasMore {
current := int64(rt.Int("cursor"))
next, parseErr := strconv.ParseInt(page.Next, 10, 64)
if parseErr != nil || next <= current {
return dingResponseError("im/list_ding_messages", "stalled_cursor", "nextCursor 没有严格前进")
}
}
return outputDingPage(rt, messages, page)
},
}
var ReceiverStatus = shortcut.Shortcut{
Service: "ding", Command: "+receiver-status", Product: "im",
Description: "查询 DING 消息接收人已读状态", Intent: "当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。", Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "ding",
Name: "shortcut_receiver_status",
CanonicalPath: "ding.shortcut_receiver_status",
CLIPath: "ding +receiver-status",
PrimaryCLIPath: "ding +receiver-status",
},
Description: "查询 DING 消息接收人已读状态",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询 DING 消息接收人已读状态",
UseWhen: []string{"当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +receiver-status --ding-id <DING_ID>"},
},
},
Flags: []shortcut.Flag{
{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
},
Description: "查询 DING 消息接收人已读状态",
Intent: "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。",
Risk: shortcut.RiskRead,
Safety: dingReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: dingContract(
"+receiver-status", "查询 DING 消息接收人已读状态",
"已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。",
true, dingReceiverResult(), nil,
[]contract.ParamDecl{{Name: "ding-id", Property: "dingId"}},
"dws ding +receiver-status --ding-id <DING_ID>",
),
Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
Tips: []string{"dws ding +receiver-status --ding-id <DING_ID>"},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("list_ding_receiver_status", map[string]any{
"openDingId": rt.Str("ding-id"),
})
const operation = "im/list_ding_receiver_status"
data, err := rt.CallMCPData("im", "list_ding_receiver_status", map[string]any{"openDingId": rt.Str("ding-id")})
if err != nil {
return err
}
receivers, err := dingProjectReceivers(data, operation, rt.Str("ding-id"))
if err != nil {
return err
}
payload := map[string]any{"count": len(receivers), "receivers": receivers}
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
meta := &output.Meta{Count: output.NewCount(len(receivers))}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
},
}
var SendPersonal = shortcut.Shortcut{
Service: "ding", Command: "+send-personal", Product: "im",
Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "ding",
Name: "shortcut_send_personal",
CanonicalPath: "ding.shortcut_send_personal",
CLIPath: "ding +send-personal",
PrimaryCLIPath: "ding +send-personal",
},
Description: "以本人身份发送 DING 给指定人",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "以本人身份发送 DING 给指定人",
UseWhen: []string{"当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +send-personal --users <VALUES> --content <CONTENT>"},
},
},
Flags: []shortcut.Flag{
var SendPersonal = compatibilityDingWrite(
"+send-personal", "以本人身份发送 DING 给指定人",
"仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。",
shortcut.RiskWrite, false,
[]shortcut.Flag{
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true},
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式:app、sms 或 call"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
[]contract.ParamDecl{
{Name: "users", Property: "users", InterfaceType: "array"},
{Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"},
},
"dws ding +send-personal --users <VALUES> --content <CONTENT>",
func(rt *shortcut.RuntimeContext) error {
remindType, err := dingPersonalRemindType(rt.Str("type"))
if err != nil {
return err
}
params := map[string]any{
"receiverOpenDingTalkIds": rt.StrSlice("users"),
"content": rt.Str("content"),
"remindType": rt.Str("type"),
"remindType": remindType,
}
if rt.Changed("uuid") {
params["uuid"] = rt.Str("uuid")
}
return rt.CallMCP("send_personal_ding", params)
},
}
)
var SendByMessage = shortcut.Shortcut{
Service: "ding", Command: "+send-by-message", Product: "im",
Description: "针对某条消息发起 DING 提醒", Intent: "当群里已有一条聊天消息需要被重点跟进,你想直接把它变成 DING 去强提醒相关人(而不是另写新内容)时使用;需提供群 openConversationId、该消息 openMessageId 和接收人 openDingTalkId 列表,会真实基于这条消息发出 DING。", Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
var SendByMessage = unavailableDingWrite(
"+send-by-message", "针对某条消息发起 DING 提醒",
"需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。",
shortcut.RiskWrite, false,
[]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"openConversationId": rt.Str("group"),
"openMessageId": rt.Str("message-id"),
"receiverOpenDingTalkIds": rt.StrSlice("users"),
"remindType": rt.Str("type"),
}
if rt.Changed("uuid") {
params["uuid"] = rt.Str("uuid")
}
return rt.CallMCP("send_ding_by_message", params)
[]contract.ParamDecl{
{Name: "group", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"},
{Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"},
{Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"},
},
"dws ding +send-by-message --group <GROUP_ID> --message-id <MESSAGE_ID> --users <VALUES>",
)
var RecallPersonal = compatibilityDingWrite(
"+recall-personal", "撤回本人发起的 DING",
"仅为历史 CLI 兼容保留:明确确认后按稳定 openDingId 撤回;因终态与残留通知尚不可查询验证,不进入 Agent 公共发现。",
shortcut.RiskHighWrite, true,
[]shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
[]contract.ParamDecl{{Name: "id", Property: "id"}},
"dws ding +recall-personal --id <DING_ID>",
func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("recall_personal_ding", map[string]any{"openDingId": rt.Str("id")})
},
)
func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
declaration := dingContract(command, description, intent, true, nil, nil, params, example)
declaration.Interface.Reason = dingCompatibilityWriteReason
return shortcut.Shortcut{
Service: "ding", Command: command, Product: "im",
Description: description, Intent: intent, Risk: risk,
Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive,
Contract: declaration,
Flags: flags, Tips: []string{example}, Execute: execute,
}
}
var RecallPersonal = shortcut.Shortcut{
Service: "ding", Command: "+recall-personal", Product: "im",
Description: "撤回本人发起的 DING", Intent: "当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。", Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "ding",
Name: "shortcut_recall_personal",
CanonicalPath: "ding.shortcut_recall_personal",
CLIPath: "ding +recall-personal",
PrimaryCLIPath: "ding +recall-personal",
func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut {
return shortcut.Shortcut{
Service: "ding", Command: command, Product: "im",
Description: description, Intent: intent, Risk: risk,
Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive,
Contract: dingContract(command, description, intent, false, nil, nil, params, example),
Flags: flags, Tips: []string{example},
Execute: func(*shortcut.RuntimeContext) error {
return dingUnavailable("ding/" + command)
},
Description: "撤回本人发起的 DING",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "撤回本人发起的 DING",
UseWhen: []string{"当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +recall-personal --id <ID>"},
},
},
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("recall_personal_ding", map[string]any{
"openDingId": rt.Str("id"),
})
},
}
}
func dingPersonalRemindType(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "app":
return "APP", nil
case "sms":
return "SMS", nil
case "call":
return "PHONE", nil
default:
return "", apperrors.NewValidation("--type 必须是 app、sms 或 call")
}
}
func init() {
shortcut.Register(
List,
ReceiverStatus,
SendPersonal,
SendByMessage,
RecallPersonal,
)
shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal)
}
@@ -0,0 +1,434 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package ding
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"math"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type dingCoverageCaller struct {
responses map[string][]string
failures map[string]error
history []string
arguments []map[string]any
}
func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
caller.history = append(caller.history, tool)
caller.arguments = append(caller.arguments, arguments)
if err := caller.failures[tool]; err != nil {
return nil, err
}
queue := caller.responses[tool]
if len(queue) == 0 {
return nil, errors.New("missing DING fake response for " + tool)
}
caller.responses[tool] = queue[1:]
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
}
func (*dingCoverageCaller) Format() string { return "json" }
func (*dingCoverageCaller) DryRun() bool { return false }
func (*dingCoverageCaller) Fields() string { return "" }
func (*dingCoverageCaller) JQ() string { return "" }
func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) (*cobra.Command, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd, cmd.Execute()
}
func runDingCoverageOutput(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) ([]byte, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
return stdout.Bytes(), err
}
func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} {
if declaration.Contract.Empty() {
t.Errorf("%s lacks Contract", declaration.Command)
}
if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" {
t.Errorf("%s lacks Safety", declaration.Command)
}
if declaration.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
}
}
for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus} {
if declaration.Contract.Result == nil {
t.Errorf("%s lacks Result", declaration.Command)
}
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
t.Errorf("%s is not interface-available", declaration.Command)
}
}
for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} {
if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
t.Errorf("%s compatibility write safety/interface drift", declaration.Command)
}
}
if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" {
t.Errorf("%s unavailable write safety/interface drift", SendByMessage.Command)
}
if List.Contract.Pagination == nil {
t.Fatal("+list lacks cursor pagination")
}
}
func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) {
valid := map[string]any{
"success": true,
"result": map[string]any{
"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": "fixture"}},
"hasMore": true,
"nextCursor": float64(2),
},
}
messages, page, err := dingProjectMessages(valid, "im/list_ding_messages")
if err != nil || len(messages) != 1 || !page.HasMore || page.Next != "2" || messages[0]["content"] != "fixture" {
t.Fatalf("valid list projection messages=%#v page=%+v err=%v", messages, page, err)
}
empty := map[string]any{"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": nil}}
if messages, page, err := dingProjectMessages(empty, "im/list_ding_messages"); err != nil || len(messages) != 0 || page.HasMore {
t.Fatalf("explicit terminal empty page messages=%#v page=%+v err=%v", messages, page, err)
}
fixtures := map[string]map[string]any{
"empty": {},
"missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
"wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
"false success": {"success": false, "errorMsg": "fixture failure", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
"missing result": {"success": true},
"missing collection": {"success": true, "result": map[string]any{"hasMore": false}},
"wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}},
"bad item": {"success": true, "result": map[string]any{"dingMessages": []any{"bad"}, "hasMore": false}},
"missing item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"dingContent": "fixture"}}, "hasMore": false}},
"duplicate item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "same"}, map[string]any{"openDingId": "same"}}, "hasMore": false}},
"missing pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}}},
"wrong pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": "false"}},
"empty continuation": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": true, "nextCursor": float64(2)}},
"missing next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true}},
"wrong next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true, "nextCursor": "2"}},
"conflicting terminal": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": float64(2)}},
"wrong optional content": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": float64(1)}}, "hasMore": false}},
}
for name, fixture := range fixtures {
if projected, _, projectErr := dingProjectMessages(fixture, "im/list_ding_messages"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
if _, _, err := dingProjectMessages(map[string]any{"success": false, "errorMsg": "fixture failure"}, "im/list_ding_messages"); err == nil || !strings.Contains(err.Error(), "fixture failure") {
t.Fatalf("remote failure message was not preserved: %v", err)
}
}
func TestCrossPlatformCoverageDINGIntegerRepresentations(t *testing.T) {
for name, test := range map[string]struct {
value any
want int64
}{
"float64": {value: float64(1), want: 1},
"int": {value: int(2), want: 2},
"int64": {value: int64(3), want: 3},
"json number": {value: json.Number("4"), want: 4},
} {
t.Run(name, func(t *testing.T) {
if got, ok := dingInteger(test.value); !ok || got != test.want {
t.Fatalf("dingInteger(%T(%v))=(%d,%t), want (%d,true)", test.value, test.value, got, ok, test.want)
}
})
}
for name, value := range map[string]any{
"nan": math.NaN(),
"fraction": 1.5,
"overflow": math.MaxFloat64,
"bad json number": json.Number("not-an-integer"),
"wrong type": "1",
} {
t.Run(name, func(t *testing.T) {
if got, ok := dingInteger(value); ok {
t.Fatalf("dingInteger(%T(%v))=(%d,true), want invalid", value, value, got)
}
})
}
}
func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) {
valid := map[string]any{"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}
items, err := dingProjectReceivers(valid, "im/list_ding_receiver_status", "ding-1")
if err != nil || len(items) != 1 || items[0]["confirmedStatus"] != int64(1) {
t.Fatalf("valid receiver projection=%#v err=%v", items, err)
}
for name, fixture := range map[string]map[string]any{
"empty": {},
"missing collection": {"success": true, "result": map[string]any{}},
"empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}},
"bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}},
"missing item id": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"confirmedStatus": float64(1), "receiverNick": "fixture"}}}},
"identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}},
"wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}},
"missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}},
} {
if projected, projectErr := dingProjectReceivers(fixture, "im/list_ding_receiver_status", "ding-1"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
}
func TestCrossPlatformCoverageDINGListExecutionFailuresPaginationAndLegacy(t *testing.T) {
t.Run("negative cursor validates before call", func(t *testing.T) {
caller := &dingCoverageCaller{responses: map[string][]string{}}
if _, err := runDingCoverage(t, List, caller, "--cursor", "-1"); err == nil || len(caller.history) != 0 {
t.Fatalf("negative cursor error=%v calls=%v", err, caller.history)
}
})
t.Run("transport failure", func(t *testing.T) {
caller := &dingCoverageCaller{
responses: map[string][]string{},
failures: map[string]error{"list_ding_messages": errors.New("fixture transport failure")},
}
if _, err := runDingCoverage(t, List, caller); err == nil || strings.Join(caller.history, ",") != "list_ding_messages" {
t.Fatalf("transport error=%v calls=%v", err, caller.history)
}
})
t.Run("projection failure", func(t *testing.T) {
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[],"hasMore":true,"nextCursor":2}}`},
}}
if _, err := runDingCoverage(t, List, caller); err == nil {
t.Fatal("malformed empty continuation returned success")
}
})
t.Run("stalled cursor", func(t *testing.T) {
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":2}}`},
}}
if _, err := runDingCoverage(t, List, caller, "--cursor", "2"); err == nil || !strings.Contains(err.Error(), "nextCursor") {
t.Fatalf("stalled cursor error=%v", err)
}
})
t.Run("advancing cursor legacy output", func(t *testing.T) {
legacy := List
legacy.OutputRollout = output.RolloutLegacyOnly
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":3}}`},
}}
stdout, err := runDingCoverageOutput(t, legacy, caller, "--cursor", "2")
if err != nil {
t.Fatalf("legacy advancing page: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout, &payload); err != nil || payload["nextCursor"] != "3" || payload["complete"] != false {
t.Fatalf("legacy payload=%#v decode=%v", payload, err)
}
if len(caller.arguments) != 1 || caller.arguments[0]["cursor"] != 2 {
t.Fatalf("legacy cursor arguments=%#v", caller.arguments)
}
})
t.Run("invalid unified page evidence", func(t *testing.T) {
cmd := corecmd.New(shortcut.FromShortcut(List))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
rt := shortcut.RuntimeContextForTest(cmd, List)
if err := outputDingPage(rt, nil, dingPageEvidence{HasMore: true}); err == nil {
t.Fatal("continuation without next cursor returned success")
}
})
}
func TestCrossPlatformCoverageDINGReceiverExecutionFailuresAndLegacy(t *testing.T) {
t.Run("transport failure", func(t *testing.T) {
caller := &dingCoverageCaller{
responses: map[string][]string{},
failures: map[string]error{"list_ding_receiver_status": errors.New("fixture transport failure")},
}
if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil {
t.Fatal("receiver transport failure returned success")
}
})
t.Run("projection failure", func(t *testing.T) {
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_receiver_status": {`{"success":true,"result":{"receivers":[]}}`},
}}
if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil {
t.Fatal("empty receiver response returned success")
}
})
t.Run("legacy output", func(t *testing.T) {
legacy := ReceiverStatus
legacy.OutputRollout = output.RolloutLegacyOnly
caller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`},
}}
stdout, err := runDingCoverageOutput(t, legacy, caller, "--ding-id", "ding-1")
if err != nil {
t.Fatalf("receiver legacy output: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout, &payload); err != nil || payload["count"] != float64(1) {
t.Fatalf("receiver legacy payload=%#v decode=%v", payload, err)
}
if len(caller.arguments) != 1 || caller.arguments[0]["openDingId"] != "ding-1" {
t.Fatalf("receiver arguments=%#v", caller.arguments)
}
})
}
func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) {
listCaller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`},
}}
cmd, err := runDingCoverage(t, List, listCaller, "--type", "ALL")
if err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
cmd.SetOut(&stdout)
if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 {
t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr)
}
var envelope map[string]any
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
data := envelope["data"].(map[string]any)
if _, leaked := data["result"]; leaked || data["count"] != float64(1) || data["complete"] != true {
t.Fatalf("list unified projection=%#v", data)
}
if strings.Join(listCaller.history, ",") != "list_ding_messages" {
t.Fatalf("list call history=%v", listCaller.history)
}
receiverCaller := &dingCoverageCaller{responses: map[string][]string{
"list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`},
}}
if _, err := runDingCoverage(t, ReceiverStatus, receiverCaller, "--ding-id", "ding-1"); err != nil {
t.Fatal(err)
}
if strings.Join(receiverCaller.history, ",") != "list_ding_receiver_status" {
t.Fatalf("receiver call history=%v", receiverCaller.history)
}
}
func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) {
args := []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"}
unconfirmed := &dingCoverageCaller{responses: map[string][]string{}}
err := runDingRoot(t, SendByMessage, unconfirmed, false, args...)
if err == nil || len(unconfirmed.history) != 0 {
t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history)
}
confirmed := &dingCoverageCaller{responses: map[string][]string{}}
err = runDingRoot(t, SendByMessage, confirmed, true, args...)
if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 {
t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history)
}
}
func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) {
for input, want := range map[string]string{"app": "APP", " sms ": "SMS", "call": "PHONE"} {
got, err := dingPersonalRemindType(input)
if err != nil || got != want {
t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want (%q,nil)", input, got, err, want)
}
}
sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"}
unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}}
if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 {
t.Fatalf("unconfirmed send error=%v calls=%v", err, unconfirmedSend.history)
}
confirmedSend := &dingCoverageCaller{responses: map[string][]string{
"send_personal_ding": {`{"success":true,"result":{"openDingId":"ding-fixture"}}`},
}}
if err := runDingRoot(t, SendPersonal, confirmedSend, true, sendArgs...); err != nil {
t.Fatalf("confirmed send failed: %v", err)
}
if strings.Join(confirmedSend.history, ",") != "send_personal_ding" || len(confirmedSend.arguments) != 1 {
t.Fatalf("confirmed send calls=%v args=%v", confirmedSend.history, confirmedSend.arguments)
}
sendParams := confirmedSend.arguments[0]
users, _ := sendParams["receiverOpenDingTalkIds"].([]string)
if strings.Join(users, ",") != "D-fixture" || sendParams["content"] != "fixture" || sendParams["remindType"] != "PHONE" || sendParams["uuid"] != "fixture-uuid" {
t.Fatalf("confirmed send params=%#v", sendParams)
}
invalidType := &dingCoverageCaller{responses: map[string][]string{}}
if err := runDingRoot(t, SendPersonal, invalidType, true, "--users", "D-fixture", "--content", "fixture", "--type", "other"); err == nil || len(invalidType.history) != 0 {
t.Fatalf("invalid type error=%v calls=%v", err, invalidType.history)
}
recallArgs := []string{"--id", "ding-fixture"}
unconfirmedRecall := &dingCoverageCaller{responses: map[string][]string{}}
if err := runDingRoot(t, RecallPersonal, unconfirmedRecall, false, recallArgs...); err == nil || len(unconfirmedRecall.history) != 0 {
t.Fatalf("unconfirmed recall error=%v calls=%v", err, unconfirmedRecall.history)
}
confirmedRecall := &dingCoverageCaller{responses: map[string][]string{
"recall_personal_ding": {`{"success":true,"result":true}`},
}}
if err := runDingRoot(t, RecallPersonal, confirmedRecall, true, recallArgs...); err != nil {
t.Fatalf("confirmed recall failed: %v", err)
}
if strings.Join(confirmedRecall.history, ",") != "recall_personal_ding" || confirmedRecall.arguments[0]["openDingId"] != "ding-fixture" {
t.Fatalf("confirmed recall calls=%v args=%v", confirmedRecall.history, confirmedRecall.arguments)
}
}
func runDingRoot(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, yes bool, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: 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)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
service := &cobra.Command{Use: "ding"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
argv := []string{"ding", declaration.Command}
argv = append(argv, args...)
if yes {
argv = append(argv, "--yes")
}
root.SetArgs(argv)
return root.Execute()
}
+4
View File
@@ -19,6 +19,7 @@ package doc
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"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/localio"
@@ -564,6 +565,9 @@ var CommentReply = shortcut.Shortcut{
"replyCommentKey": rt.Str("comment-key"),
}
if rt.Bool("emoji") {
if err := commentreaction.Validate(rt.Str("content")); err != nil {
return err
}
params["emoji"] = true
}
if rt.Changed("mention") {
@@ -132,6 +132,31 @@ func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *doc
return root.Execute()
}
func TestCrossPlatformCoverageCommentReplyRejectsUnsupportedEmojiBeforeRPC(t *testing.T) {
for _, content := range []string{"😄", "乱码"} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
err := runDocCoverage(t, CommentReply, caller,
"--node", "node-1", "--comment-key", "comment-1",
"--content", content, "--emoji", "--yes")
if err == nil {
t.Fatalf("unsupported reaction %q accepted", content)
}
if caller.calls != 0 {
t.Fatalf("unsupported reaction %q reached RPC %d time(s)", content, caller.calls)
}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, CommentReply, caller,
"--node", "node-1", "--comment-key", "comment-1",
"--content", "鼓掌", "--emoji", "--yes"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.history[0].params["emoji"] != true {
t.Fatalf("valid reaction call = %#v", caller.history)
}
}
func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) {
revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}}
if got, ok := nestedRevision(revisionPayload); !ok || got != 9 {
+415
View File
@@ -0,0 +1,415 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package oa
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const oaCompositeReason = "Reviewed OA Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable identity checks, truthful pagination, output projection, and confirmation; no single MCP interface represents the complete command contract."
type oaPageEvidence struct {
Known bool
HasMore bool
Next string
}
func oaCollectionResult(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":"严格验证后的 OA 审批业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`,
description, collection, description, collection,
)),
SensitivePaths: []string{collection + ".title", collection + ".originatorName", collection + ".formValueVOS", collection + ".userId"},
}
}
func oaObjectResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格验证且身份匹配的 OA 审批对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
description,
)),
SensitivePaths: []string{"value.title", "value.processInstanceTitle", "value.formValueVOS", "value.originatorUserid", "value.operationRecords", "value.userId"},
}
}
func oaWriteResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"processInstanceId":{"type":"string","description":"被处理审批实例的稳定 ID"},"taskId":{"type":"string","description":"被处理审批任务的稳定 ID"},"verified":{"type":"boolean","description":"写后精确任务读回是否证明目标任务不再待处理"}},"required":["processInstanceId","taskId","verified"],"additionalProperties":false}`,
description,
)),
SensitivePaths: []string{"processInstanceId", "taskId"},
}
}
func oaReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func oaWriteSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
}
func oaContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "oa " + command
availability := contract.InterfaceUnavailable
interfaceReason := strings.TrimSpace(intent)
if available {
availability = contract.InterfaceAvailable
interfaceReason = oaCompositeReason
}
return corecmd.ContractDecl{
Description: description,
Result: result,
Pagination: pagination,
Parameters: params,
Identity: contract.ToolIdentitySpec{
ProductID: "oa", Name: name, CanonicalPath: "oa." + name,
CLIPath: cliPath, PrimaryCLIPath: cliPath,
},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: interfaceReason},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{"只需原始 OA MCP 响应时使用 oa approval 原子命令;缺少稳定实例、任务或表单身份时不要猜测"},
Examples: examples,
},
}
}
func oaPagePagination(parameter string) *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: parameter,
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func oaResponseError(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 oaPostWriteError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("write_verification"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
// oaRequireSuccess accepts only the two success encodings observed from the
// OA backend. Missing, false, null, and all other encodings fail closed.
func oaRequireSuccess(data map[string]any, operation string) error {
if len(data) == 0 {
return oaResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 OA 调用成功或结果确实为空")
}
switch value := data["success"].(type) {
case bool:
if value {
return nil
}
case string:
if value == "true" {
return nil
}
}
if _, present := data["success"]; !present {
return oaResponseError(operation, "missing_success", "OA 响应缺少 success 业务状态")
}
message := oaFirstString(data, "errorMessage", "errorMsg", "message", "error")
if message == "" {
message = "OA 服务未明确返回成功状态"
}
return oaResponseError(operation, "remote_failure", message)
}
func oaLookup(object map[string]any, path string) (any, bool) {
var current any = object
for _, segment := range strings.Split(path, ".") {
mapped, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = mapped[segment]
if !ok {
return nil, false
}
}
return current, true
}
func oaRequireObject(data map[string]any, operation, path string) (map[string]any, error) {
if err := oaRequireSuccess(data, operation); err != nil {
return nil, err
}
value, present := oaLookup(data, path)
if !present || value == nil {
return nil, oaResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path))
}
object, ok := value.(map[string]any)
if !ok || len(object) == 0 {
return nil, oaResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value))
}
return object, nil
}
func oaRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
if err := oaRequireSuccess(data, operation); err != nil {
return nil, err
}
value, present := oaLookup(data, path)
if !present {
return nil, oaResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
}
raw, ok := value.([]any)
if !ok {
return nil, oaResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
}
items := make([]map[string]any, 0, len(raw))
for index, item := range raw {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
}
items = append(items, object)
}
return items, nil
}
func oaFirstString(object map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func oaScalarString(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
default:
return ""
}
}
func oaIdentity(object map[string]any, keys ...string) string {
for _, key := range keys {
if value := oaScalarString(object[key]); value != "" {
return value
}
}
return ""
}
func oaRequireIdentity(object map[string]any, operation, expected string, keys ...string) error {
actual := oaIdentity(object, keys...)
if actual == "" {
return oaResponseError(operation, "missing_stable_id", "OA 业务对象缺少稳定 ID")
}
if expected != "" && actual != expected {
return oaResponseError(operation, "identity_mismatch", "OA 业务对象 ID 与请求目标不一致")
}
return nil
}
func oaProjectForms(data map[string]any, operation, path string) ([]map[string]any, error) {
items, err := oaRequireCollection(data, operation, path)
if err != nil {
return nil, err
}
forms := make([]map[string]any, 0, len(items))
for index, item := range items {
code := oaIdentity(item, "processCode")
name := oaFirstString(item, "processName", "name")
if code == "" || name == "" {
return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("表单结果第 %d 项缺少 processCode 或名称", index))
}
row := map[string]any{"processCode": code, "name": name}
if icon := oaFirstString(item, "processIconUrl", "iconUrl"); icon != "" {
row["iconUrl"] = icon
}
forms = append(forms, row)
}
return forms, nil
}
func oaProjectInstances(data map[string]any, operation, path string) ([]map[string]any, error) {
items, err := oaRequireCollection(data, operation, path)
if err != nil {
return nil, err
}
instances := make([]map[string]any, 0, len(items))
for index, item := range items {
id := oaIdentity(item, "processInstanceId")
if id == "" {
return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批结果第 %d 项缺少 processInstanceId", index))
}
row := map[string]any{"processInstanceId": id}
if title := oaFirstString(item, "title", "processInstanceTitle"); title != "" {
row["title"] = title
}
if status := oaScalarString(item["status"]); status != "" {
row["status"] = status
}
if businessID := oaIdentity(item, "businessId"); businessID != "" {
row["businessId"] = businessID
}
if originator := oaFirstString(item, "originatorName", "originatorUserName"); originator != "" {
row["originatorName"] = originator
}
for _, key := range []string{"processCreateTime", "createTime"} {
if value, present := item[key]; present && value != nil {
row["createTime"] = value
break
}
}
instances = append(instances, row)
}
return instances, nil
}
func oaProjectTasks(data map[string]any, operation string) ([]map[string]any, error) {
items, err := oaRequireCollection(data, operation, "result.taskIdList")
if err != nil {
return nil, err
}
tasks := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id := oaIdentity(item, "taskId")
if id == "" {
return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批任务第 %d 项缺少 taskId", index))
}
if _, duplicate := seen[id]; duplicate {
return nil, oaResponseError(operation, "duplicate_item_identity", "审批任务响应包含重复 taskId")
}
seen[id] = struct{}{}
tasks = append(tasks, map[string]any{"taskId": id})
}
return tasks, nil
}
func oaCursorPage(result map[string]any, operation string, current int) (oaPageEvidence, error) {
raw, present := result["hasMore"]
if !present {
return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "游标响应缺少 hasMore;不能把当前页当作完整结果")
}
hasMore, ok := raw.(bool)
if !ok {
return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值")
}
page := oaPageEvidence{Known: true, HasMore: hasMore}
next := oaScalarString(result["nextCursor"])
if !hasMore {
if next != "" {
return oaPageEvidence{}, oaResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空")
}
return page, nil
}
if next == "" {
return oaPageEvidence{}, oaResponseError(operation, "missing_next_cursor", "hasMore=true 但缺少 nextCursor")
}
nextValue, err := strconv.Atoi(next)
if err != nil {
return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "nextCursor 应为整数")
}
if nextValue <= current {
return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 没有严格前进")
}
page.Next = next
return page, nil
}
func oaHasMorePage(result map[string]any, operation string, currentPage int) (oaPageEvidence, error) {
raw, present := result["hasMore"]
if !present {
return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "审批列表缺少 hasMore,无法证明结果完整或提供续页凭据")
}
hasMore, ok := raw.(bool)
if !ok {
return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值")
}
page := oaPageEvidence{Known: true, HasMore: hasMore}
if hasMore {
if currentPage <= 0 {
return oaPageEvidence{}, oaResponseError(operation, "invalid_page", "当前页码无效,无法生成下一页凭据")
}
page.Next = strconv.Itoa(currentPage + 1)
}
return page, nil
}
func outputOAPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, page oaPageEvidence) error {
if !page.Known {
return oaResponseError("oa/pagination", "missing_pagination", "响应缺少分页终态证据")
}
payload := map[string]any{"count": len(items), collection: items, "complete": !page.HasMore}
if !output.UsesUnifiedResult(rt.Command()) {
if page.HasMore {
payload["nextPage"] = page.Next
}
return rt.Output(payload)
}
pagination, err := output.NewPagination(!page.HasMore, page.Next)
if err != nil {
return oaResponseError("oa/pagination", "invalid_pagination", err.Error())
}
meta := &output.Meta{Count: output.NewCount(len(items)), Pagination: pagination}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
}
func outputOACompleteCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any) error {
payload := map[string]any{"count": len(items), collection: items, "complete": true}
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
meta := &output.Meta{Count: output.NewCount(len(items))}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
}
func validateOAPage(page, limit int) error {
if page <= 0 {
return apperrors.NewValidation("--page 必须大于 0")
}
if limit <= 0 || limit > 100 {
return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
}
return nil
}
@@ -1,82 +0,0 @@
// 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 oa
import (
"encoding/json"
"testing"
)
// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss
// class: list_user_visible_process nests the forms under result.processCodeList.
// The resolver MUST probe that exact key — otherwise the whole list silently
// projects to empty (exit 0, no error envelope) while the backend has data.
func TestListFormsProjectProcessCodeListShape(t *testing.T) {
// Faithful list_user_visible_process shape (as returned by the backend).
const raw = `{"result":{"processCodeList":[
{"processCode":"PROC-1","processName":"leave","dirName":"attendance"},
{"processCode":"PROC-2","processName":"check-in fix","dirName":"attendance"},
{"processCode":"PROC-3","processName":"overtime","dirName":"attendance"}
],"totalCount":-1},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
forms := listFormsProject(data)
if len(forms) != 3 {
t.Fatalf("lower/upper mismatch: 3 forms in backend, projection returned %d (forms=%v)", len(forms), forms)
}
for _, f := range forms {
if f["processCode"] == nil || f["name"] == nil {
t.Fatalf("projected form missing processCode/name: %v", f)
}
}
}
// TestListFormsProjectBareArrayShape ensures a bare top-level array still works.
func TestListFormsProjectBareArrayShape(t *testing.T) {
const raw = `{"result":[
{"processCode":"PROC-9","processName":"generic approval"}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if forms := listFormsProject(data); len(forms) != 1 {
t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms)
}
}
// TestOAInstanceResolveListValuesShape guards the shared resolver behind
// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval
// instance tools nest the list under result.values; the resolver must probe
// "values" or all four commands silently return empty despite backend records.
func TestOAInstanceResolveListValuesShape(t *testing.T) {
const raw = `{"result":{"hasMore":false,"values":[
{"processInstanceId":"i-1","title":"user leave"},
{"processInstanceId":"i-2","title":"user reimbursement"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := oaInstanceResolveList(data); len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.values has 2 entries, resolver returned %d", len(got))
}
// End-to-end through a real projection that uses the shared resolver.
if instances := listSubmittedProject(data); len(instances) != 2 {
t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances)
}
}
+188 -537
View File
@@ -1,59 +1,73 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Licensed under the Apache License, Version 2.0
// Package oa registers declarative shortcuts for the DingTalk OA approval
// service, wrapping the raw MCP tools exposed by the dws oa helper.
// Package oa registers strict declarative shortcuts for DingTalk OA approval.
package oa
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// ListPending — 查询待我处理的审批 (list_pending_approvals)
func parseOAStringPage(rt *shortcut.RuntimeContext, name string, fallback int) (int, error) {
raw := rt.Str(name)
if raw == "" {
return fallback, nil
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是整数", name))
}
return value, nil
}
func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string]any, page int) error {
operation := "oa/" + tool
data, err := rt.CallMCPData("oa", tool, params)
if err != nil {
return err
}
instances, err := oaProjectInstances(data, operation, "result.values")
if err != nil {
return err
}
result, _ := data["result"].(map[string]any)
evidence, err := oaHasMorePage(result, operation, page)
if err != nil {
return err
}
return outputOAPage(rt, "instances", instances, evidence)
}
var ListPending = shortcut.Shortcut{
Service: "oa",
Command: "+list-pending",
Product: "oa",
Description: "查询待我处理的审批(时间范围为 epoch 毫秒)",
Intent: "当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_pending",
CanonicalPath: "oa.shortcut_list_pending",
CLIPath: "oa +list-pending",
PrimaryCLIPath: "oa +list-pending",
Service: "oa", Command: "+list-pending", Product: "oa",
Description: "查询待我处理的审批(时间范围为 epoch 毫秒)",
Intent: "按 epoch 毫秒时间范围查询当前用户待处理审批;只有显式成功、严格实例数组与可续页证据齐全时才返回结果。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+list-pending",
"查询待我处理的审批(时间范围为 epoch 毫秒)",
"需要按时间范围读取待我审批的实例,并取得稳定 processInstanceId 时使用;没有安全非空待办 fixture 前不会进入公开发现。",
true,
oaCollectionResult("instances", "严格验证的待处理审批实例页"),
oaPagePagination("page"),
[]contract.ParamDecl{
{Name: "start", Property: "start"},
{Name: "end", Property: "end"},
{Name: "page", Property: "page"},
{Name: "limit", Property: "limit"},
{Name: "query", Property: "query"},
},
Description: "查询待我处理的审批(时间范围为 epoch 毫秒)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询待我处理的审批(时间范围为 epoch 毫秒)",
UseWhen: []string{"当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销"},
},
},
"dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20",
),
Flags: []shortcut.Flag{
{Name: "start", Type: shortcut.FlagInt, Desc: "开始时间(epoch 毫秒)", Required: true},
{Name: "end", Type: shortcut.FlagInt, Desc: "结束时间(epoch 毫秒)", Required: true},
@@ -61,12 +75,25 @@ var ListPending = shortcut.Shortcut{
{Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"starTime": rt.Int("start"),
"endTime": rt.Int("end"),
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end", "page", "limit"}, Description: "--start/--end 必须是递增的正整数 epoch 毫秒;--page 必须大于 0;--limit 必须在 1-100"}},
Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("start") <= 0 || rt.Int("end") <= rt.Int("start") {
return apperrors.NewValidation("--start/--end 必须是递增的正整数 epoch 毫秒范围")
}
page, err := parseOAStringPage(rt, "page", 1)
if err != nil {
return err
}
limit, err := parseOAStringPage(rt, "limit", 20)
if err != nil {
return err
}
return validateOAPage(page, limit)
},
Execute: func(rt *shortcut.RuntimeContext) error {
page, _ := parseOAStringPage(rt, "page", 1)
params := map[string]any{"starTime": rt.Int("start"), "endTime": rt.Int("end")}
if rt.Changed("page") {
params["pageNum"] = rt.Str("page")
}
@@ -76,530 +103,154 @@ var ListPending = shortcut.Shortcut{
if rt.Changed("query") {
params["query"] = rt.Str("query")
}
data, err := rt.CallMCPData("oa", "list_pending_approvals", params)
if err != nil {
return err
}
instances := listPendingProject(data)
return rt.Output(map[string]any{"count": len(instances), "instances": instances})
return oaInstancePage(rt, "list_pending_approvals", params, page)
},
}
// listPendingProject reshapes the raw list_pending_approvals response into the
// same clean {processInstanceId, title, status, createTime} approval list as
// +list-executed/+list-submitted, so all approval-instance listings project
// identically. It reuses the shared oaInstance* defensive probes, tolerating
// response-shape and key-spelling drift.
func listPendingProject(data map[string]any) []map[string]any {
raw := oaInstanceResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaInstanceProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// Detail — 获取审批实例详情 (get_processInstance_detail)
// Approve — 同意审批 (approve_processInstance)
// Reject — 拒绝审批 (reject_processInstance)
// Revoke — 撤销已发起的审批 (revoke_processInstance)
// Records — 获取审批操作记录 (get_processInstance_records)
// ListInitiated — 查询审批模板下已发起的审批记录 (list_initiated_instances)
// ListTasks — 查询待我审批的任务 ID (list_pending_tasks)
// ListForms — 获取当前用户可见的审批表单列表 (list_user_visible_process)
var ListForms = shortcut.Shortcut{
Service: "oa",
Command: "+list-forms",
Product: "oa",
Description: "获取当前用户可见的审批表单列表",
Intent: "当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_forms",
CanonicalPath: "oa.shortcut_list_forms",
CLIPath: "oa +list-forms",
PrimaryCLIPath: "oa +list-forms",
},
Description: "获取当前用户可见的审批表单列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取当前用户可见的审批表单列表",
UseWhen: []string{"当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-forms --cursor 0 --limit 100"},
},
},
Service: "oa", Command: "+list-forms", Product: "oa",
Description: "获取当前用户可见的审批表单列表",
Intent: "按服务端游标读取当前用户可发起的审批表单;缺少 hasMore/nextCursor 或游标不前进时失败,不把重复首页宣称为完整列表。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+list-forms", "获取当前用户可见的审批表单列表",
"需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故不进入 Agent 公开发现。",
true,
oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"),
[]contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}},
"dws oa +list-forms --cursor 0 --limit 100",
),
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"},
},
Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor", "limit"}, Description: "--cursor 不能小于 0;--limit 必须在 1-100"}},
Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("cursor") < 0 {
return apperrors.NewValidation("--cursor 不能小于 0")
}
if rt.Int("limit") <= 0 || rt.Int("limit") > 100 {
return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{
"cursor": rt.Int("cursor"),
"pageSize": rt.Int("limit"),
})
const operation = "oa/list_user_visible_process"
data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{"cursor": rt.Int("cursor"), "pageSize": rt.Int("limit")})
if err != nil {
return err
}
forms := listFormsProject(data)
return rt.Output(map[string]any{"count": len(forms), "forms": forms})
forms, err := oaProjectForms(data, operation, "result.processCodeList")
if err != nil {
return err
}
result, _ := data["result"].(map[string]any)
page, err := oaCursorPage(result, operation, rt.Int("cursor"))
if err != nil {
return err
}
return outputOAPage(rt, "forms", forms, page)
},
}
// listFormsProject reshapes the raw list_user_visible_process response into a
// clean approval-form list ({processCode, name, iconUrl}) — the output-projection
// fidelity the framework applies to every list command. The list container and
// per-item field names are probed defensively across candidate keys so the
// projection tolerates response-shape drift rather than crashing or fabricating.
func listFormsProject(data map[string]any) []map[string]any {
raw := oaFormResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaFormProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// oaFormResolveList locates the form list inside a response, tolerating a bare
// top-level array or nesting one level under a common envelope key.
func oaFormResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// list_user_visible_process nests the forms under result.processCodeList;
// probing must include that exact key (both at the top level in case the
// envelope is already unwrapped, and one level deeper under result/data) or
// the whole list silently projects to empty.
for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// oaFormProjectItem picks the stable identity/label fields of a single approval
// form, probing candidate key spellings so it survives snake/camel drift.
func oaFormProjectItem(m map[string]any) map[string]any {
row := map[string]any{}
if v, ok := oaFormFirst(m, "processCode", "process_code", "code"); ok {
row["processCode"] = v
}
if v, ok := oaFormFirst(m, "name", "processName", "process_name", "flowTitle"); ok {
row["name"] = v
}
if v, ok := oaFormFirst(m, "iconUrl", "icon_url", "iconName"); ok {
row["iconUrl"] = v
}
return row
}
// oaFormFirst returns the first present candidate key's value.
func oaFormFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// SearchForms — 按关键字模糊搜索可见审批表单 (search_form)
var SearchForms = shortcut.Shortcut{
Service: "oa",
Command: "+search-forms",
Product: "oa",
Description: "按关键字模糊搜索当前用户可见的审批表单",
Intent: "当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
Service: "oa", Command: "+search-forms", Product: "oa",
Description: "按关键字模糊搜索当前用户可见的审批表单",
Intent: "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+search-forms", "按关键字模糊搜索当前用户可见的审批表单",
"已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。",
true,
oaCollectionResult("forms", "严格验证的审批表单搜索结果"), nil,
[]contract.ParamDecl{{Name: "query", Property: "query"}},
"dws oa +search-forms --query 报销",
),
Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称);去除空白后不能为空", Required: true}},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}},
Tips: []string{`dws oa +search-forms --query 报销`},
Validate: func(rt *shortcut.RuntimeContext) error {
if strings.TrimSpace(rt.Str("query")) == "" {
return apperrors.NewValidation("--query 不能为空")
}
return nil
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_search_forms",
CanonicalPath: "oa.shortcut_search_forms",
CLIPath: "oa +search-forms",
PrimaryCLIPath: "oa +search-forms",
},
Description: "按关键字模糊搜索当前用户可见的审批表单",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按关键字模糊搜索当前用户可见的审批表单",
UseWhen: []string{"当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +search-forms --query 报销"},
},
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true},
},
Tips: []string{`dws oa +search-forms --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("oa", "search_form", map[string]any{
"query": rt.Str("query"),
})
const operation = "oa/search_form"
data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": strings.TrimSpace(rt.Str("query"))})
if err != nil {
return err
}
forms := searchFormsProject(data)
return rt.Output(map[string]any{"count": len(forms), "forms": forms})
},
}
// searchFormsProject reshapes the raw search_form response into the same clean
// {processCode, name, iconUrl} list as +list-forms, so both approval-form
// listings project identically. It reuses the shared oaForm* defensive probes,
// tolerating response-shape and key-spelling drift.
func searchFormsProject(data map[string]any) []map[string]any {
raw := oaFormResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaFormProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// DingInfo — 获取审批任务的被催办人 userId (oa_ding_user)
// ListExecuted — 获取当前用户已处理过的审批单列表 (get_done_tasks)
var ListExecuted = shortcut.Shortcut{
Service: "oa",
Command: "+list-executed",
Product: "oa",
Description: "获取当前用户已经处理过的审批单列表",
Intent: "当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_executed",
CanonicalPath: "oa.shortcut_list_executed",
CLIPath: "oa +list-executed",
PrimaryCLIPath: "oa +list-executed",
},
Description: "获取当前用户已经处理过的审批单列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取当前用户已经处理过的审批单列表",
UseWhen: []string{"当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-executed --limit 20 --page 1 --query 报销"},
},
},
Flags: []shortcut.Flag{
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"},
{Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
Tips: []string{`dws oa +list-executed --limit 20 --page 1 --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"pageNumber": rt.Str("page"),
"pageSize": rt.Str("limit"),
}
if rt.Changed("query") {
params["query"] = rt.Str("query")
}
data, err := rt.CallMCPData("oa", "get_done_tasks", params)
forms, err := oaProjectForms(data, operation, "result")
if err != nil {
return err
}
instances := listExecutedProject(data)
return rt.Output(map[string]any{"count": len(instances), "instances": instances})
return outputOACompleteCollection(rt, "forms", forms)
},
}
// listExecutedProject reshapes the raw get_done_tasks response into a clean
// approval-instance list ({processInstanceId, title, status, createTime}) — the
// the clean output projection applied to every list command. The
// list container and per-item field names are probed defensively across
// candidate keys so the projection tolerates response-shape drift rather than
// crashing or fabricating data.
func listExecutedProject(data map[string]any) []map[string]any {
raw := oaInstanceResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaInstanceProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// oaInstanceResolveList locates the approval-instance list inside a response,
// tolerating a bare top-level array or nesting one level under a common
// envelope key.
func oaInstanceResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// The approval instance tools (list_pending_approvals, get_done_tasks,
// get_submitted_instances, get_noticed_instances) all nest the instance list
// under result.values; "values" MUST be in the probe set or every one of
// +list-pending / +list-executed / +list-submitted / +list-cc silently
// projects to empty despite the backend returning records.
for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// oaInstanceProjectItem picks the stable identity/label fields of a single
// approval instance, probing candidate key spellings so it survives
// snake/camel drift.
func oaInstanceProjectItem(m map[string]any) map[string]any {
row := map[string]any{}
if v, ok := oaFormFirst(m, "processInstanceId", "process_instance_id", "instanceId", "id"); ok {
row["processInstanceId"] = v
}
if v, ok := oaFormFirst(m, "title", "processTitle", "process_title", "name"); ok {
row["title"] = v
}
if v, ok := oaFormFirst(m, "status", "result", "processResult"); ok {
row["status"] = v
}
if v, ok := oaFormFirst(m, "createTime", "create_time", "gmtCreate", "createdTime"); ok {
row["createTime"] = v
}
return row
}
// ListSubmitted — 获取当前用户已发起的审批单列表 (get_submitted_instances)
var ListSubmitted = shortcut.Shortcut{
Service: "oa",
Command: "+list-submitted",
Product: "oa",
Description: "获取当前用户已发起的审批单列表",
Intent: "当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_submitted",
CanonicalPath: "oa.shortcut_list_submitted",
CLIPath: "oa +list-submitted",
PrimaryCLIPath: "oa +list-submitted",
func oaNumberedInstanceShortcut(command, tool, description, intent string) shortcut.Shortcut {
declaration := shortcut.Shortcut{
Service: "oa", Command: command, Product: "oa",
Description: description, Intent: intent, Risk: shortcut.RiskRead,
Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(command, description, intent, true,
oaCollectionResult("instances", description), oaPagePagination("page"),
[]contract.ParamDecl{{Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}},
"dws oa "+command+" --page 1 --limit 20"),
Flags: []shortcut.Flag{
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码;--page 必须大于 0"},
{Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小;--limit 必须在 1-100"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
Description: "获取当前用户已发起的审批单列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取当前用户已发起的审批单列表",
UseWhen: []string{"当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-submitted --limit 20 --page 1 --query 报销"},
},
},
Flags: []shortcut.Flag{
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"},
{Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
Tips: []string{`dws oa +list-submitted --limit 20 --page 1 --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"pageNumber": rt.Str("page"),
"pageSize": rt.Str("limit"),
}
if rt.Changed("query") {
params["query"] = rt.Str("query")
}
data, err := rt.CallMCPData("oa", "get_submitted_instances", params)
Tips: []string{"dws oa " + command + " --page 1 --limit 20"},
}
declaration.Validate = func(rt *shortcut.RuntimeContext) error {
page, err := parseOAStringPage(rt, "page", 1)
if err != nil {
return err
}
instances := listSubmittedProject(data)
return rt.Output(map[string]any{"count": len(instances), "instances": instances})
},
}
// listSubmittedProject reshapes the raw get_submitted_instances response into
// the same clean {processInstanceId, title, status, createTime} approval list
// as +list-executed, so both instance listings project identically. It reuses
// the shared oaInstance* defensive probes, tolerating response-shape and
// key-spelling drift.
func listSubmittedProject(data map[string]any) []map[string]any {
raw := oaInstanceResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaInstanceProjectItem(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// ListCc — 获取抄送当前用户的审批单列表 (get_noticed_instances)
var ListCc = shortcut.Shortcut{
Service: "oa",
Command: "+list-cc",
Product: "oa",
Description: "获取抄送当前用户的审批单列表",
Intent: "当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_list_cc",
CanonicalPath: "oa.shortcut_list_cc",
CLIPath: "oa +list-cc",
PrimaryCLIPath: "oa +list-cc",
},
Description: "获取抄送当前用户的审批单列表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取抄送当前用户的审批单列表",
UseWhen: []string{"当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws oa +list-cc --limit 20 --page 1 --query 报销"},
},
},
Flags: []shortcut.Flag{
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"},
{Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
Tips: []string{`dws oa +list-cc --limit 20 --page 1 --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"pageNumber": rt.Str("page"),
"pageSize": rt.Str("limit"),
}
if rt.Changed("query") {
params["query"] = rt.Str("query")
}
data, err := rt.CallMCPData("oa", "get_noticed_instances", params)
limit, err := parseOAStringPage(rt, "limit", 20)
if err != nil {
return err
}
instances := listCcProject(data)
return rt.Output(map[string]any{"count": len(instances), "instances": instances})
},
}
// listCcProject reshapes the raw get_noticed_instances response into the same
// clean {processInstanceId, title, status, createTime} approval list as the
// other instance listings, so all approval-instance listings project
// identically. It reuses the shared oaInstance* defensive probes, tolerating
// response-shape and key-spelling drift.
func listCcProject(data map[string]any) []map[string]any {
raw := oaInstanceResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if row := oaInstanceProjectItem(m); len(row) > 0 {
out = append(out, row)
}
return validateOAPage(page, limit)
}
return out
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
page, _ := parseOAStringPage(rt, "page", 1)
params := map[string]any{"pageNumber": rt.Str("page"), "pageSize": rt.Str("limit")}
if query := strings.TrimSpace(rt.Str("query")); query != "" {
params["query"] = query
}
return oaInstancePage(rt, tool, params, page)
}
return declaration
}
// RedirectTask — 转交审批任务给其他人 (redirect_task)
// Comment — 对审批实例添加评论 (dingflow_comments)
// CcNotice — 对审批实例进行抄送 (oa_cc_noticer)
// AppendTask — 对审批任务进行加签 (append_task)
// RevertActivities — 获取审批任务可回退的节点信息 (get_inst_revert_activities)
// RevertTask — 退回审批任务到指定节点 (revert_task)
var ListExecuted = oaNumberedInstanceShortcut(
"+list-executed", "get_done_tasks", "获取当前用户已经处理过的审批单列表",
"需要回顾当前用户已同意或拒绝过的审批实例时使用;与待办、已发起和抄送列表分开。",
)
var ListSubmitted = oaNumberedInstanceShortcut(
"+list-submitted", "get_submitted_instances", "获取当前用户已发起的审批单列表",
"需要查看当前用户发起的审批实例和当前状态时使用;返回稳定 processInstanceId 与可续页证据。",
)
var ListCc = oaNumberedInstanceShortcut(
"+list-cc", "get_noticed_instances", "获取抄送当前用户的审批单列表",
"需要查看抄送给当前用户的审批实例时使用;没有安全非空抄送 fixture 前不会进入公开发现。",
)
func init() {
shortcut.Register(
ListPending,
ListForms,
SearchForms,
ListExecuted,
ListSubmitted,
ListCc,
)
shortcut.Register(ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, ListCc)
}
@@ -0,0 +1,728 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package oa
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type oaCoverageCaller struct {
responses map[string][]string
history []string
arguments []map[string]any
}
func (caller *oaCoverageCaller) 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 OA fake response for " + tool)
}
caller.responses[tool] = queue[1:]
if queue[0] == "__ERROR__" {
return nil, errors.New("injected OA failure")
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
}
func (*oaCoverageCaller) Format() string { return "json" }
func (*oaCoverageCaller) DryRun() bool { return false }
func (*oaCoverageCaller) Fields() string { return "" }
func (*oaCoverageCaller) JQ() string { return "" }
func runOACoverage(t *testing.T, declaration shortcut.Shortcut, caller *oaCoverageCaller, args ...string) (*cobra.Command, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd, cmd.Execute()
}
func runOAConfirmedCoverage(t *testing.T, caller *oaCoverageCaller, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: 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)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
service := &cobra.Command{Use: "oa"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve)))
root.AddCommand(service)
root.SetArgs(append([]string{"oa", "+approve-by"}, args...))
return root.Execute()
}
func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{
ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted,
ListCc, PendingApprovals, DoneApprovals, Approve, MyInitiated,
} {
if declaration.Contract.Empty() || declaration.Contract.Result == nil {
t.Errorf("%s lacks Contract.Result", declaration.Command)
}
if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" {
t.Errorf("%s lacks Safety", declaration.Command)
}
if declaration.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
}
}
if Approve.Safety.Confirmation != "user_required" || Approve.Risk != shortcut.RiskHighWrite {
t.Fatal("+approve-by must require explicit high-risk confirmation")
}
unavailable := oaContract("+fixture-unavailable", "fixture unavailable", "reviewed fixture is unavailable", false, oaCollectionResult("items", "fixture items"), nil, nil)
if unavailable.Interface == nil || unavailable.Interface.Availability != "unavailable" || unavailable.Interface.Reason != "reviewed fixture is unavailable" {
t.Fatalf("unavailable OA contract interface=%+v", unavailable.Interface)
}
for _, declaration := range []shortcut.Shortcut{ListPending, ListForms, ListExecuted, ListSubmitted, ListCc, MyInitiated} {
if declaration.Contract.Pagination == nil {
t.Errorf("%s lacks Pagination", declaration.Command)
}
}
}
func TestCrossPlatformCoverageOAStrictResponseMatrix(t *testing.T) {
validEmpty := map[string]any{"success": "true", "result": map[string]any{"values": []any{}}}
items, err := oaProjectInstances(validEmpty, "oa/test", "result.values")
if err != nil || len(items) != 0 {
t.Fatalf("explicit empty must succeed: items=%v err=%v", items, err)
}
for name, fixture := range map[string]map[string]any{
"empty": {},
"missing success": {"result": map[string]any{"values": []any{}}},
"false success": {"success": false, "result": map[string]any{"values": []any{}}},
"missing collection": {"success": true, "result": map[string]any{}},
"wrong collection": {"success": true, "result": map[string]any{"values": map[string]any{}}},
"bad item": {"success": true, "result": map[string]any{"values": []any{"bad"}}},
"missing identity": {"success": true, "result": map[string]any{"values": []any{map[string]any{"title": "fixture"}}}},
} {
if projected, err := oaProjectInstances(fixture, "oa/test", "result.values"); err == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
forms := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}}
if projected, err := oaProjectForms(forms, "oa/forms", "result.processCodeList"); err != nil || len(projected) != 1 {
t.Fatalf("strict forms projection=%#v err=%v", projected, err)
}
badForm := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processName": "fixture"}}}}
if projected, err := oaProjectForms(badForm, "oa/forms", "result.processCodeList"); err == nil {
t.Fatalf("form without processCode returned success: %#v", projected)
}
search := map[string]any{"success": true, "result": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}
if projected, err := oaProjectForms(search, "oa/search", "result"); err != nil || len(projected) != 1 {
t.Fatalf("strict search projection=%#v err=%v", projected, err)
}
}
func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) {
if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1); err == nil {
t.Fatal("numbered page without hasMore was accepted")
}
if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 1); err == nil {
t.Fatal("wrong hasMore type was accepted")
}
page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 2)
if err != nil || !page.HasMore || page.Next != "3" {
t.Fatalf("numbered continuation=%+v err=%v", page, err)
}
if _, err := oaCursorPage(map[string]any{}, "oa/cursor", 0); err == nil {
t.Fatal("cursor response without pagination was accepted")
}
if _, err := oaCursorPage(map[string]any{"hasMore": true}, "oa/cursor", 0); err == nil {
t.Fatal("hasMore without nextCursor was accepted")
}
if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 1); err == nil {
t.Fatal("stalled cursor was accepted")
}
if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 2); err == nil {
t.Fatal("backward cursor was accepted")
}
if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": "not-a-number"}, "oa/cursor", 1); err == nil {
t.Fatal("non-integer cursor was accepted")
}
page, err = oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(2)}, "oa/cursor", 1)
if err != nil || page.Next != "2" {
t.Fatalf("cursor continuation=%+v err=%v", page, err)
}
}
func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) {
caller := &oaCoverageCaller{responses: map[string][]string{
"get_submitted_instances": {`{"success":"true","result":{"values":[],"hasMore":false}}`},
}}
cmd, err := runOACoverage(t, MyInitiated, caller, "--page", "1", "--limit", "20")
if err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
cmd.SetOut(&stdout)
code, emitted, err := output.EmitStoredResult(cmd)
if err != nil || !emitted || code != 0 {
t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err)
}
var envelope struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if _, raw := envelope.Data["result"]; raw {
t.Fatalf("raw result leaked: %s", stdout.String())
}
initiated, ok := envelope.Data["initiated"].([]any)
if !ok || len(initiated) != 0 || envelope.Data["complete"] != true {
t.Fatalf("strict empty initiated payload=%#v", envelope.Data)
}
}
func TestCrossPlatformCoverageOAApproveConfirmationAndReadback(t *testing.T) {
unconfirmed := &oaCoverageCaller{responses: map[string][]string{}}
helpers.InitDepsForTest(t, unconfirmed)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: 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)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
service := &cobra.Command{Use: "oa"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve)))
root.AddCommand(service)
root.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture"})
if err := root.Execute(); err == nil {
t.Fatal("unconfirmed approval unexpectedly succeeded")
}
if len(unconfirmed.history) != 0 {
t.Fatalf("unconfirmed approval made calls: %v", unconfirmed.history)
}
confirmed := &oaCoverageCaller{responses: map[string][]string{
"list_pending_approvals": {`{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"instance-1","title":"fixture"}]}}`},
"list_pending_tasks": {
`{"success":true,"result":{"taskIdList":[{"taskId":1}]}}`,
`{"success":true,"result":{"taskIdList":[]}}`,
},
"approve_processInstance": {`{"success":true,"result":{"accepted":true}}`},
}}
helpers.InitDepsForTest(t, confirmed)
confirmedRoot := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
confirmedRoot.PersistentFlags().Bool("yes", false, "")
confirmedRoot.PersistentFlags().Bool("dry-run", false, "")
confirmedRoot.PersistentFlags().String("format", "json", "")
confirmedCtx, _ := output.WithResultStore(context.Background())
confirmedRoot.SetContext(confirmedCtx)
confirmedRoot.SetOut(io.Discard)
confirmedRoot.SetErr(io.Discard)
confirmedService := &cobra.Command{Use: "oa"}
confirmedService.AddCommand(corecmd.New(shortcut.FromShortcut(Approve)))
confirmedRoot.AddCommand(confirmedService)
confirmedRoot.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture", "--yes"})
if err := confirmedRoot.Execute(); err != nil {
t.Fatal(err)
}
if got := strings.Join(confirmed.history, ","); got != "list_pending_approvals,list_pending_tasks,approve_processInstance,list_pending_tasks" {
t.Fatalf("call history=%s", got)
}
if len(confirmed.arguments) != 4 || confirmed.arguments[2]["processInstanceId"] != "instance-1" || confirmed.arguments[3]["processInstanceId"] != "instance-1" {
t.Fatalf("identity was not preserved: %#v", confirmed.arguments)
}
}
func TestCrossPlatformCoverageOACommonHelperBranches(t *testing.T) {
if oaObjectResult("fixture") == nil || oaPostWriteError("oa/test", "fixture", "fixture") == nil {
t.Fatal("typed object result and post-write error must be constructed")
}
for name, fixture := range map[string]map[string]any{
"false with backend message": {"success": false, "errorMessage": "fixture"},
"false without message": {"success": false},
"null success": {"success": nil},
"wrong success string": {"success": "TRUE"},
} {
if err := oaRequireSuccess(fixture, "oa/test"); err == nil {
t.Errorf("%s was accepted", name)
}
}
if _, ok := oaLookup(map[string]any{"result": "bad"}, "result.values"); ok {
t.Fatal("lookup traversed a non-object")
}
if _, ok := oaLookup(map[string]any{"result": map[string]any{}}, "result.values"); ok {
t.Fatal("lookup accepted a missing segment")
}
for name, fixture := range map[string]map[string]any{
"nil object": {"success": true, "result": nil},
"wrong object": {"success": true, "result": "bad"},
"empty object": {"success": true, "result": map[string]any{}},
} {
if value, err := oaRequireObject(fixture, "oa/test", "result"); err == nil {
t.Errorf("%s returned object %#v", name, value)
}
}
if value, err := oaRequireObject(map[string]any{"success": false}, "oa/test", "result"); err == nil {
t.Fatalf("failed envelope returned object %#v", value)
}
object, err := oaRequireObject(map[string]any{"success": true, "result": map[string]any{"id": "fixture"}}, "oa/test", "result")
if err != nil || object["id"] != "fixture" {
t.Fatalf("valid object=%#v err=%v", object, err)
}
for name, value := range map[string]any{
"string": " fixture ",
"json number": json.Number("42"),
"float": float64(42),
"int": int(42),
"int64": int64(42),
"unsupported": true,
} {
got := oaScalarString(value)
if name == "unsupported" && got != "" {
t.Errorf("unsupported scalar=%q", got)
}
if name != "unsupported" && got == "" {
t.Errorf("%s scalar was empty", name)
}
}
identityFixture := map[string]any{"id": int64(42)}
if err := oaRequireIdentity(identityFixture, "oa/test", "42", "id"); err != nil {
t.Fatal(err)
}
if err := oaRequireIdentity(map[string]any{}, "oa/test", "", "id"); err == nil {
t.Fatal("missing identity was accepted")
}
if err := oaRequireIdentity(identityFixture, "oa/test", "43", "id"); err == nil {
t.Fatal("mismatched identity was accepted")
}
formsFixture := map[string]any{"success": true, "result": []any{map[string]any{
"processCode": "p", "name": "fixture", "iconUrl": "icon",
}}}
forms, err := oaProjectForms(formsFixture, "oa/forms", "result")
if err != nil || forms[0]["iconUrl"] != "icon" {
t.Fatalf("fallback form fields=%#v err=%v", forms, err)
}
instanceFixture := map[string]any{"success": true, "result": map[string]any{"values": []any{map[string]any{
"processInstanceId": "i", "processInstanceTitle": "title", "status": json.Number("2"),
"businessId": 7, "originatorUserName": "owner", "createTime": int64(9),
}}}}
instances, err := oaProjectInstances(instanceFixture, "oa/instances", "result.values")
if err != nil {
t.Fatal(err)
}
wantInstance := map[string]any{"processInstanceId": "i", "title": "title", "status": "2", "businessId": "7", "originatorName": "owner", "createTime": int64(9)}
if !reflect.DeepEqual(instances[0], wantInstance) {
t.Fatalf("projected instance=%#v want=%#v", instances[0], wantInstance)
}
for name, fixture := range map[string]map[string]any{
"task projection error": {"success": true, "result": map[string]any{}},
"missing task id": {"success": true, "result": map[string]any{"taskIdList": []any{map[string]any{"x": 1}}}},
"duplicate task id": {"success": true, "result": map[string]any{"taskIdList": []any{
map[string]any{"taskId": 1}, map[string]any{"taskId": 1},
}}},
} {
if tasks, err := oaProjectTasks(fixture, "oa/tasks"); err == nil {
t.Errorf("%s returned %#v", name, tasks)
}
}
for name, fixture := range map[string]map[string]any{
"wrong cursor hasMore": {"hasMore": "true"},
"terminal conflict": {"hasMore": false, "nextCursor": 1},
} {
if page, err := oaCursorPage(fixture, "oa/cursor", 0); err == nil {
t.Errorf("%s returned %+v", name, page)
}
}
terminal, err := oaCursorPage(map[string]any{"hasMore": false}, "oa/cursor", 0)
if err != nil || !terminal.Known || terminal.HasMore || terminal.Next != "" {
t.Fatalf("terminal cursor=%+v err=%v", terminal, err)
}
if _, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 0); err == nil {
t.Fatal("continuation from invalid current page was accepted")
}
terminal, err = oaHasMorePage(map[string]any{"hasMore": false}, "oa/page", 1)
if err != nil || !terminal.Known || terminal.HasMore {
t.Fatalf("terminal numbered page=%+v err=%v", terminal, err)
}
if err := validateOAPage(0, 1); err == nil {
t.Fatal("zero page was accepted")
}
if err := validateOAPage(1, 0); err == nil {
t.Fatal("zero limit was accepted")
}
if err := validateOAPage(1, 101); err == nil {
t.Fatal("oversized limit was accepted")
}
}
func TestCrossPlatformCoverageOAOutputBranches(t *testing.T) {
legacyPage := shortcut.Shortcut{
Service: "oa", Command: "+coverage-legacy-page", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead,
Execute: func(rt *shortcut.RuntimeContext) error {
return outputOAPage(rt, "items", []map[string]any{{"id": "1"}}, oaPageEvidence{Known: true, HasMore: true, Next: "2"})
},
}
_, err := runOACoverage(t, legacyPage, &oaCoverageCaller{responses: map[string][]string{}})
if err != nil {
t.Fatal(err)
}
unknownPage := legacyPage
unknownPage.Command = "+coverage-unknown-page"
unknownPage.Execute = func(rt *shortcut.RuntimeContext) error {
return outputOAPage(rt, "items", nil, oaPageEvidence{})
}
if _, err := runOACoverage(t, unknownPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil {
t.Fatal("unknown pagination was accepted")
}
legacyComplete := shortcut.Shortcut{
Service: "oa", Command: "+coverage-legacy-complete", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead,
Execute: func(rt *shortcut.RuntimeContext) error { return outputOACompleteCollection(rt, "items", nil) },
}
if _, err := runOACoverage(t, legacyComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil {
t.Fatal(err)
}
unifiedComplete := legacyComplete
unifiedComplete.Command = "+coverage-unified-complete"
unifiedComplete.OutputRollout = output.RolloutUnifiedActive
if _, err := runOACoverage(t, unifiedComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil {
t.Fatal(err)
}
badUnifiedPage := legacyPage
badUnifiedPage.Command = "+coverage-unified-bad-page"
badUnifiedPage.OutputRollout = output.RolloutUnifiedActive
badUnifiedPage.Execute = func(rt *shortcut.RuntimeContext) error {
return outputOAPage(rt, "items", nil, oaPageEvidence{Known: true, HasMore: true})
}
if _, err := runOACoverage(t, badUnifiedPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil {
t.Fatal("unified continuation without token was accepted")
}
}
func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) {
expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
t.Helper()
caller := &oaCoverageCaller{responses: responses}
if _, err := runOACoverage(t, declaration, caller, args...); err == nil {
t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args)
}
return caller
}
expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
t.Helper()
caller := &oaCoverageCaller{responses: responses}
if _, err := runOACoverage(t, declaration, caller, args...); err != nil {
t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err)
}
return caller
}
validPage := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}`
for name, args := range map[string][]string{
"bad interval": {"--start", "2", "--end", "1"},
"bad page": {"--start", "1", "--end", "2", "--page", "bad"},
"bad limit": {"--start", "1", "--end", "2", "--limit", "bad"},
"zero page": {"--start", "1", "--end", "2", "--page", "0"},
} {
t.Run("list-pending validate "+name, func(t *testing.T) {
caller := expectError(t, ListPending, map[string][]string{}, args...)
if len(caller.history) != 0 {
t.Fatalf("validation made calls: %v", caller.history)
}
})
}
t.Run("list-pending call failure", func(t *testing.T) {
expectError(t, ListPending, map[string][]string{"list_pending_approvals": {"__ERROR__"}}, "--start", "1", "--end", "2")
})
t.Run("list-pending projection failure", func(t *testing.T) {
expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}, "--start", "1", "--end", "2")
})
t.Run("list-pending null result fails closed", func(t *testing.T) {
expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}, "--start", "1", "--end", "2")
})
t.Run("list-pending pagination failure", func(t *testing.T) {
expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}, "--start", "1", "--end", "2")
})
t.Run("list-pending full params", func(t *testing.T) {
caller := expectSuccess(t, ListPending, map[string][]string{"list_pending_approvals": {validPage}},
"--start", "1", "--end", "2", "--page", "2", "--limit", "3", "--query", "fixture")
want := map[string]any{"starTime": 1, "endTime": 2, "pageNum": "2", "pageSize": "3", "query": "fixture"}
if !reflect.DeepEqual(caller.arguments[0], want) {
t.Fatalf("list pending params=%#v want=%#v", caller.arguments[0], want)
}
})
for name, args := range map[string][]string{
"negative cursor": {"--cursor", "-1"},
"zero limit": {"--limit", "0"},
"oversized limit": {"--limit", "101"},
} {
t.Run("list-forms validate "+name, func(t *testing.T) {
expectError(t, ListForms, map[string][]string{}, args...)
})
}
t.Run("list-forms call failure", func(t *testing.T) {
expectError(t, ListForms, map[string][]string{"list_user_visible_process": {"__ERROR__"}})
})
t.Run("list-forms projection failure", func(t *testing.T) {
expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":false}}`}})
})
t.Run("list-forms null result fails closed", func(t *testing.T) {
expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":null}`}})
})
t.Run("list-forms pagination failure", func(t *testing.T) {
expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"processCodeList":[]}}`}})
})
t.Run("list-forms success", func(t *testing.T) {
expectSuccess(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":true,"nextCursor":2,"processCodeList":[{"processCode":"p","processName":"fixture"}]}}`}}, "--cursor", "1", "--limit", "2")
})
t.Run("search empty query", func(t *testing.T) {
caller := expectError(t, SearchForms, map[string][]string{}, "--query", " ")
if len(caller.history) != 0 {
t.Fatalf("blank query made calls: %v", caller.history)
}
})
t.Run("search call failure", func(t *testing.T) {
expectError(t, SearchForms, map[string][]string{"search_form": {"__ERROR__"}}, "--query", "fixture")
})
t.Run("search projection failure", func(t *testing.T) {
expectError(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":{}}`}}, "--query", "fixture")
})
t.Run("search known nonempty", func(t *testing.T) {
caller := expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", " fixture ")
if got := caller.arguments[0]["query"]; got != "fixture" {
t.Fatalf("normalized query=%#v, want fixture", got)
}
})
t.Run("search legitimate empty", func(t *testing.T) {
expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[]}`}}, "--query", "guaranteed-zero-fixture")
})
for _, declaration := range []shortcut.Shortcut{ListExecuted, ListSubmitted, ListCc} {
declaration := declaration
tool := map[string]string{"+list-executed": "get_done_tasks", "+list-submitted": "get_submitted_instances", "+list-cc": "get_noticed_instances"}[declaration.Command]
t.Run(declaration.Command+" invalid page", func(t *testing.T) {
expectError(t, declaration, map[string][]string{}, "--page", "bad")
})
t.Run(declaration.Command+" invalid limit", func(t *testing.T) {
expectError(t, declaration, map[string][]string{}, "--limit", "bad")
})
t.Run(declaration.Command+" call failure", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}})
})
t.Run(declaration.Command+" full params", func(t *testing.T) {
caller := expectSuccess(t, declaration, map[string][]string{tool: {validPage}}, "--page", "2", "--limit", "3", "--query", " fixture ")
want := map[string]any{"pageNumber": "2", "pageSize": "3", "query": "fixture"}
if !reflect.DeepEqual(caller.arguments[0], want) {
t.Fatalf("params=%#v want=%#v", caller.arguments[0], want)
}
})
}
}
func TestCrossPlatformCoverageOACompatibilityReadBranches(t *testing.T) {
expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
t.Helper()
caller := &oaCoverageCaller{responses: responses}
if _, err := runOACoverage(t, declaration, caller, args...); err == nil {
t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args)
}
return caller
}
expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
t.Helper()
caller := &oaCoverageCaller{responses: responses}
if _, err := runOACoverage(t, declaration, caller, args...); err != nil {
t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err)
}
return caller
}
terminal := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}`
for _, declaration := range []shortcut.Shortcut{PendingApprovals, DoneApprovals} {
declaration := declaration
tool := map[string]string{"+pending": "list_pending_approvals", "+done-approvals": "get_done_tasks"}[declaration.Command]
t.Run(declaration.Command+" invalid limit", func(t *testing.T) {
caller := expectError(t, declaration, map[string][]string{}, "--limit", "0")
if len(caller.history) != 0 {
t.Fatalf("validation made calls: %v", caller.history)
}
})
t.Run(declaration.Command+" call failure", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}})
})
t.Run(declaration.Command+" projection failure", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":false}}`}})
})
t.Run(declaration.Command+" null result fails closed", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":null}`}})
})
t.Run(declaration.Command+" pagination failure", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"values":[]}}`}})
})
t.Run(declaration.Command+" refuses incomplete first page", func(t *testing.T) {
expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":true,"values":[]}}`}})
})
t.Run(declaration.Command+" default limit", func(t *testing.T) {
expectSuccess(t, declaration, map[string][]string{tool: {terminal}})
})
t.Run(declaration.Command+" explicit limit", func(t *testing.T) {
caller := expectSuccess(t, declaration, map[string][]string{tool: {terminal}}, "--limit", "3")
if caller.arguments[0]["pageSize"] != float64(3) {
t.Fatalf("explicit limit params=%#v", caller.arguments[0])
}
})
}
for name, args := range map[string][]string{
"bad page": {"--page", "0"},
"bad limit": {"--limit", "101"},
} {
t.Run("my-initiated validate "+name, func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{}, args...)
})
}
t.Run("my-initiated call failure", func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {"__ERROR__"}})
})
t.Run("my-initiated projection failure", func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":false}}`}})
})
t.Run("my-initiated null result fails closed", func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":null}`}})
})
t.Run("my-initiated pagination failure", func(t *testing.T) {
expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"values":[]}}`}})
})
t.Run("my-initiated query and continuation", func(t *testing.T) {
caller := expectSuccess(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}, "--query", "fixture", "--page", "2", "--limit", "3")
want := map[string]any{"pageNumber": float64(2), "pageSize": float64(3), "query": "fixture"}
if !reflect.DeepEqual(caller.arguments[0], want) {
t.Fatalf("my initiated params=%#v want=%#v", caller.arguments[0], want)
}
})
}
func TestCrossPlatformCoverageOARequiredFlagValidationClosures(t *testing.T) {
for _, tc := range []struct {
name string
flagName string
validate func(*shortcut.RuntimeContext) error
}{
{name: "search forms", flagName: "query", validate: SearchForms.Validate},
{name: "approve", flagName: "keyword", validate: Approve.Validate},
} {
t.Run(tc.name, func(t *testing.T) {
declaration := shortcut.Shortcut{
Service: "oa", Command: "+coverage-" + strings.ReplaceAll(tc.name, " ", "-"), Product: "oa", Description: "fixture", Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{{Name: tc.flagName, Type: shortcut.FlagString}},
Execute: tc.validate,
}
caller := &oaCoverageCaller{responses: map[string][]string{}}
if _, err := runOACoverage(t, declaration, caller); err == nil {
t.Fatal("empty required semantic value was accepted")
}
if len(caller.history) != 0 {
t.Fatalf("validation made calls: %v", caller.history)
}
})
}
}
func TestCrossPlatformCoverageOAApproveFailureLedger(t *testing.T) {
pending := func(values string, hasMore bool) string {
return `{"success":true,"result":{"hasMore":` + map[bool]string{true: "true", false: "false"}[hasMore] + `,"values":` + values + `}}`
}
tasks := func(values string) string {
return `{"success":true,"result":{"taskIdList":` + values + `}}`
}
validPending := pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, false)
validTasks := tasks(`[{"taskId":1}]`)
cases := []struct {
name string
responses map[string][]string
}{
{name: "pending call failure", responses: map[string][]string{"list_pending_approvals": {"__ERROR__"}}},
{name: "pending projection failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}},
{name: "pending null result", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}},
{name: "pending pagination failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}},
{name: "pending is incomplete", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, true)}}},
{name: "zero matches", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"other"}]`, false)}}},
{name: "task call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {"__ERROR__"}}},
{name: "task projection failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {`{"success":true,"result":{}}`}}},
{name: "task count is not one", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[]`)}}},
{name: "task identity is not numeric", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[{"taskId":"not-numeric"}]`)}}},
{name: "write call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {"__ERROR__"}}},
{name: "write business failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":false}`}}},
{name: "write missing success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"result":{"accepted":true}}`}}},
{name: "write null success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":null}`}}},
{name: "readback call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, "__ERROR__"}, "approve_processInstance": {`{"success":true}`}}},
{name: "readback malformed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, `{"success":true,"result":{}}`}, "approve_processInstance": {`{"success":true}`}}},
{name: "write not observed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, validTasks}, "approve_processInstance": {`{"success":true}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &oaCoverageCaller{responses: tc.responses}
if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--yes"); err == nil {
t.Fatalf("failure scenario succeeded; calls=%v", caller.history)
}
})
}
t.Run("empty keyword validates before calls", func(t *testing.T) {
caller := &oaCoverageCaller{responses: map[string][]string{}}
if err := runOAConfirmedCoverage(t, caller, "--keyword", " ", "--yes"); err == nil {
t.Fatal("blank keyword succeeded")
}
if len(caller.history) != 0 {
t.Fatalf("blank keyword made calls: %v", caller.history)
}
})
t.Run("comment is sent only after confirmation", func(t *testing.T) {
caller := &oaCoverageCaller{responses: map[string][]string{
"list_pending_approvals": {validPending},
"list_pending_tasks": {validTasks, tasks(`[]`)},
"approve_processInstance": {`{"success":true}`},
}}
if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--comment", "approved", "--yes"); err != nil {
t.Fatal(err)
}
if caller.arguments[2]["remark"] != "approved" {
t.Fatalf("write args=%#v", caller.arguments[2])
}
})
}
+272
View File
@@ -0,0 +1,272 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package oa
import (
"fmt"
"strconv"
"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 oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, params map[string]any) error {
operation := "oa/" + tool
data, err := rt.CallMCPData("oa", tool, params)
if err != nil {
return err
}
instances, err := oaProjectInstances(data, operation, "result.values")
if err != nil {
return err
}
result, _ := data["result"].(map[string]any)
page, err := oaHasMorePage(result, operation, 1)
if err != nil {
return err
}
if page.HasMore {
return oaResponseError(operation, "unavailable_continuation", "兼容 Shortcut 没有页码参数且结果仍有后续页;请改用对应可分页命令")
}
return outputOACompleteCollection(rt, collection, instances)
}
var PendingApprovals = shortcut.Shortcut{
Service: "oa", Command: "+pending", Product: "oa",
Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)",
Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时不进入 Agent 公开发现。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)",
"兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。",
true,
oaCollectionResult("pending", "严格验证的待审批摘要"), nil,
[]contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +pending --limit 10",
),
Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}},
Tips: []string{`dws oa +pending --limit 10`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) {
return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
now := time.Now()
params := map[string]any{"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli())}
if rt.Changed("limit") {
params["pageSize"] = float64(rt.Int("limit"))
}
return oaFirstPageOnly(rt, "list_pending_approvals", "pending", params)
},
}
var DoneApprovals = shortcut.Shortcut{
Service: "oa", Command: "+done-approvals", Product: "oa",
Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表",
Intent: "兼容入口:读取我已处理审批的首个完整页;新调用优先使用可分页的 +list-executed。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表",
"兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。",
true,
oaCollectionResult("done", "严格验证的已处理审批摘要"), nil,
[]contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +done-approvals --limit 10",
),
Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}},
Tips: []string{`dws oa +done-approvals --limit 10`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) {
return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
limit := 20
if rt.Changed("limit") {
limit = rt.Int("limit")
}
return oaFirstPageOnly(rt, "get_done_tasks", "done", map[string]any{"pageNumber": float64(1), "pageSize": float64(limit)})
},
}
var MyInitiated = shortcut.Shortcut{
Service: "oa", Command: "+my-initiated", Product: "oa",
Description: "列出我发起(提交)的审批单据",
Intent: "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。",
Risk: shortcut.RiskRead,
Safety: oaReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+my-initiated", "列出我发起(提交)的审批单据",
"需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。",
true,
oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"),
[]contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}},
"dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销",
),
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"},
{Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1);--page 必须大于 0", Default: "1"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20);--limit 必须在 1-100", Default: "20"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
},
Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`},
Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) },
Execute: func(rt *shortcut.RuntimeContext) error {
const operation = "oa/get_submitted_instances"
params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))}
if query := rt.Str("query"); query != "" {
params["query"] = query
}
data, err := rt.CallMCPData("oa", "get_submitted_instances", params)
if err != nil {
return err
}
items, err := oaProjectInstances(data, operation, "result.values")
if err != nil {
return err
}
result, _ := data["result"].(map[string]any)
page, err := oaHasMorePage(result, operation, rt.Int("page"))
if err != nil {
return err
}
return outputOAPage(rt, "initiated", items, page)
},
}
type oaApprovalMatch struct {
id string
title string
}
func oaMatchApprovals(items []map[string]any, keyword string) []oaApprovalMatch {
needle := strings.ToLower(strings.TrimSpace(keyword))
matches := make([]oaApprovalMatch, 0)
for _, item := range items {
id := oaIdentity(item, "processInstanceId")
title := oaFirstString(item, "title", "processInstanceTitle")
businessID := oaIdentity(item, "businessId")
if strings.Contains(strings.ToLower(title), needle) || strings.Contains(strings.ToLower(businessID), needle) || strings.EqualFold(id, needle) {
matches = append(matches, oaApprovalMatch{id: id, title: title})
}
}
return matches
}
var Approve = shortcut.Shortcut{
Service: "oa", Command: "+approve-by", Product: "oa",
Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前不进入 Agent 公开发现。",
Risk: shortcut.RiskHighWrite,
Safety: oaWriteSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: oaContract(
"+approve-by", "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
"仅在用户明确确认同意、关键词唯一定位实例且任务读回可验证时使用;任何歧义、分页不完整或读回失败都会阻止成功。",
true,
oaWriteResult("同意审批并通过精确任务读回验证"), nil,
[]contract.ParamDecl{{Name: "keyword", Property: "query"}, {Name: "comment", Property: "remark"}},
"dws oa +approve-by --keyword 报销",
),
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true},
{Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword"}, Description: "--keyword 去除空白后不能为空,且必须唯一匹配完整待办集合中的一条实例"}},
Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`},
Validate: func(rt *shortcut.RuntimeContext) error {
keyword := strings.TrimSpace(rt.Str("keyword"))
if keyword == "" {
return apperrors.NewValidation("--keyword 不能为空")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
keyword := strings.TrimSpace(rt.Str("keyword"))
now := time.Now()
pending, err := rt.CallMCPData("oa", "list_pending_approvals", map[string]any{
"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli()), "query": keyword,
})
if err != nil {
return err
}
items, err := oaProjectInstances(pending, "oa/list_pending_approvals", "result.values")
if err != nil {
return err
}
result, _ := pending["result"].(map[string]any)
page, err := oaHasMorePage(result, "oa/list_pending_approvals", 1)
if err != nil {
return err
}
if page.HasMore {
return oaResponseError("oa/list_pending_approvals", "ambiguous_incomplete_search", "待办搜索仍有后续页,无法证明关键词唯一")
}
matches := oaMatchApprovals(items, keyword)
if len(matches) != 1 {
return apperrors.NewValidation(fmt.Sprintf("关键词必须唯一匹配一条待审批实例,当前匹配 %d 条", len(matches)))
}
instanceID := matches[0].id
tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID})
if err != nil {
return err
}
tasks, err := oaProjectTasks(tasksData, "oa/list_pending_tasks")
if err != nil {
return err
}
if len(tasks) != 1 {
return apperrors.NewValidation(fmt.Sprintf("审批实例必须唯一对应一条待处理任务,当前为 %d 条", len(tasks)))
}
taskID := tasks[0]["taskId"].(string)
numericTaskID, err := strconv.ParseFloat(taskID, 64)
if err != nil {
return oaResponseError("oa/list_pending_tasks", "malformed_task_identity", "taskId 不是可写入审批接口的数字")
}
writeArgs := map[string]any{"processInstanceId": instanceID, "taskId": numericTaskID}
if comment := rt.Str("comment"); comment != "" {
writeArgs["remark"] = comment
}
receipt, err := rt.CallMCPWriteDataStrict("oa", "approve_processInstance", writeArgs)
if err != nil {
return err
}
if err := oaRequireSuccess(receipt, "oa/approve_processInstance"); err != nil {
return err
}
readback, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID})
if err != nil {
return oaPostWriteError("oa/list_pending_tasks", "readback_failed", "审批写入后无法读取任务状态;远端效果未知")
}
remaining, err := oaProjectTasks(readback, "oa/list_pending_tasks")
if err != nil {
return oaPostWriteError("oa/list_pending_tasks", "readback_malformed", "审批写入后的任务读回无法验证;远端效果未知")
}
for _, task := range remaining {
if task["taskId"] == taskID {
return oaPostWriteError("oa/list_pending_tasks", "write_not_observed", "审批任务写后读回仍处于待处理集合")
}
}
return rt.Output(map[string]any{"processInstanceId": instanceID, "taskId": taskID, "verified": true})
},
}
func init() {
shortcut.Register(PendingApprovals, DoneApprovals, Approve, MyInitiated)
}
@@ -265,10 +265,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"devapp\u0000+version-status": {},
"devapp\u0000+webapp-config": {},
"devapp\u0000+webapp-get": {},
"ding\u0000+list": {},
"ding\u0000+recall-personal": {},
"ding\u0000+receiver-status": {},
"ding\u0000+send-personal": {},
"doc\u0000+access-change": {},
"doc\u0000+access-grant": {},
"doc\u0000+access-revoke": {},
@@ -377,15 +374,11 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"minutes\u0000+update": {},
"minutes\u0000+upload": {},
"minutes\u0000+upload-and-analyze": {},
"oa\u0000+list-cc": {},
"oa\u0000+list-executed": {},
"oa\u0000+list-forms": {},
"oa\u0000+list-pending": {},
"oa\u0000+list-submitted": {},
"oa\u0000+my-initiated": {},
"oa\u0000+search-forms": {},
"report\u0000+inbox-list": {},
"report\u0000+outbox-list": {},
"report\u0000+report-latest": {},
"report\u0000+template-search": {},
"sheet\u0000+list-sheets": {},
"sheet\u0000+read": {},
"todo\u0000+assign": {},
+477
View File
@@ -0,0 +1,477 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package report
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const reportCompositeReason = "Reviewed Report Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable report/template identities, truthful pagination, local filtering, output projection, and multi-step detail verification."
type reportPageEvidence struct {
HasMore bool
Next string
}
func reportReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func reportCollectionResult(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":"具有稳定 reportId 的日志摘要","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"creatorUserId":{"type":"string","description":"日志创建人稳定用户 ID"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"}},"required":["reportId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"服务端分页证据是否证明当前查询已结束"}},"required":["count",%q,"complete"],"additionalProperties":false}`,
description, collection, description, collection,
)),
SensitivePaths: []string{collection + ".templateName", collection + ".creatorName", collection + ".creatorUserId"},
}
}
func reportTemplateSearchResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的日志模板搜索结果","properties":{"count":{"type":"integer","description":"匹配模板数量"},"templates":{"type":"array","description":"名称匹配且具有稳定 ID 的模板","items":{"type":"object","description":"一条日志模板摘要","properties":{"templateId":{"type":"string","description":"稳定模板 ID"},"name":{"type":"string","description":"模板名称"},"lastModifiedTime":{"type":"integer","description":"模板最后修改时间毫秒值"}},"required":["templateId","name"],"additionalProperties":false}}},"required":["count","templates"],"additionalProperties":false}`),
SensitivePaths: []string{"templates.name"},
}
}
func reportLatestResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的最近日志详情","properties":{"report":{"type":"object","description":"稳定身份匹配的日志详情","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"title":{"type":"string","description":"日志标题"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"},"fields":{"type":"array","description":"严格验证的日志字段","items":{"type":"object","description":"日志字段值","properties":{"key":{"type":"string","description":"字段名称"},"value":{"type":"string","description":"字段内容"},"sort":{"type":"integer","description":"字段排序值"},"type":{"type":"integer","description":"字段类型码"}},"required":["key","value","sort","type"],"additionalProperties":false}}},"required":["reportId","fields"],"additionalProperties":false}},"required":["report"],"additionalProperties":false}`),
SensitivePaths: []string{"report.title", "report.templateName", "report.creatorName", "report.fields.value"},
}
}
func reportPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func reportContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "report " + command
return corecmd.ContractDecl{
Description: description,
Result: result,
Pagination: pagination,
Parameters: params,
Identity: contract.ToolIdentitySpec{
ProductID: "report", Name: name, CanonicalPath: "report." + name,
CLIPath: cliPath, PrimaryCLIPath: cliPath,
},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: reportCompositeReason},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{"钉钉在线文档使用 doc,待办使用 todo,OA 审批使用 oa;缺少稳定日志或模板身份时不要猜测"},
Examples: examples,
},
}
}
func reportResponseError(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 reportRequireSuccess(data map[string]any, operation string) error {
if len(data) == 0 {
return reportResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 Report 调用成功或结果确实为空")
}
raw, present := data["success"]
if !present {
return reportResponseError(operation, "missing_success", "Report 响应缺少 success 业务状态")
}
success, ok := raw.(bool)
if !ok {
return reportResponseError(operation, "invalid_success_type", fmt.Sprintf("Report success 应为布尔值,实际为 %T", raw))
}
if success {
return nil
}
message := "Report 服务明确返回失败"
for _, key := range []string{"errorMsg", "errorMessage", "message"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
message = strings.TrimSpace(value)
break
}
}
return reportResponseError(operation, "remote_failure", message)
}
func reportObjectCollection(value any, operation, path string) ([]map[string]any, error) {
raw, ok := value.([]any)
if !ok {
return nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report %s 应为数组,实际为 %T", path, value))
}
items := make([]map[string]any, 0, len(raw))
for index, item := range raw {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report %s[%d] 不是非空对象", path, index))
}
items = append(items, object)
}
return items, nil
}
func reportListCollection(data map[string]any, operation string) ([]map[string]any, map[string]any, error) {
if err := reportRequireSuccess(data, operation); err != nil {
return nil, nil, err
}
result, present := data["result"]
if !present {
return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result 日志集合")
}
if _, ok := result.([]any); ok {
items, err := reportObjectCollection(result, operation, "result")
return items, data, err
}
container, ok := result.(map[string]any)
if !ok || container == nil {
return nil, nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report result 应为数组或带 report_list 的对象,实际为 %T", result))
}
raw, present := container["report_list"]
if !present {
return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result.report_list 数组")
}
items, err := reportObjectCollection(raw, operation, "result.report_list")
return items, container, err
}
func reportRequiredString(item map[string]any, operation string, index int, keys ...string) (string, error) {
var selected string
for _, key := range keys {
raw, present := item[key]
if !present || raw == nil {
continue
}
value, ok := raw.(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 必须是非空字符串", index, key))
}
if selected != "" && selected != value {
return "", reportResponseError(operation, "conflicting_item_identity", fmt.Sprintf("Report 结果第 %d 项的身份字段冲突", index))
}
selected = value
}
if selected == "" {
return "", reportResponseError(operation, "missing_item_identity", fmt.Sprintf("Report 结果第 %d 项缺少稳定身份", index))
}
return selected, nil
}
func reportOptionalString(item map[string]any, operation string, index int, keys ...string) (string, error) {
for _, key := range keys {
raw, present := item[key]
if !present || raw == nil {
continue
}
value, ok := raw.(string)
if !ok {
return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为字符串,实际为 %T", index, key, raw))
}
return strings.TrimSpace(value), nil
}
return "", nil
}
func reportInteger(value any) (int64, bool) {
switch typed := value.(type) {
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 {
return 0, false
}
return int64(typed), true
case int:
return int64(typed), true
case int64:
return typed, true
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
default:
return 0, false
}
}
func reportOptionalInteger(item map[string]any, operation string, index int, keys ...string) (int64, bool, error) {
for _, key := range keys {
raw, present := item[key]
if !present || raw == nil {
continue
}
value, ok := reportInteger(raw)
if !ok {
return 0, false, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为整数", index, key))
}
return value, true, nil
}
return 0, false, nil
}
func reportProjectEntries(data map[string]any, operation string) ([]map[string]any, reportPageEvidence, error) {
items, pageContainer, err := reportListCollection(data, operation)
if err != nil {
return nil, reportPageEvidence{}, err
}
entries := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id, identityErr := reportRequiredString(item, operation, index, "reportId", "report_id", "report_Id")
if identityErr != nil {
return nil, reportPageEvidence{}, identityErr
}
if _, duplicate := seen[id]; duplicate {
return nil, reportPageEvidence{}, reportResponseError(operation, "duplicate_item_identity", "当前 Report 页包含重复 reportId")
}
seen[id] = struct{}{}
projected := map[string]any{"reportId": id}
for target, aliases := range map[string][]string{
"templateName": {"templateName", "template_name", "reportTemplateName", "report_template_name"},
"creatorName": {"creatorName", "creator_name", "creatorUserName", "senderName", "authorName"},
"creatorUserId": {"creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"},
} {
value, valueErr := reportOptionalString(item, operation, index, aliases...)
if valueErr != nil {
return nil, reportPageEvidence{}, valueErr
}
if value != "" {
projected[target] = value
}
}
for target, aliases := range map[string][]string{
"createTime": {"createTime", "create_time", "gmtCreate", "sendTime"},
"modifiedTime": {"modifiedTime", "modified_time", "gmtModified", "modifyTime"},
} {
value, present, valueErr := reportOptionalInteger(item, operation, index, aliases...)
if valueErr != nil {
return nil, reportPageEvidence{}, valueErr
}
if present {
projected[target] = value
}
}
entries = append(entries, projected)
}
page, err := reportPage(data, pageContainer, operation, len(entries))
if err != nil {
return nil, reportPageEvidence{}, err
}
return entries, page, nil
}
func reportPage(data, result map[string]any, operation string, count int) (reportPageEvidence, error) {
raw, present := data["hasMore"]
if nested, exists := result["hasMore"]; exists {
if present && !sameReportScalar(raw, nested) {
return reportPageEvidence{}, reportResponseError(operation, "conflicting_pagination", "Report 顶层与 result.hasMore 冲突")
}
raw, present = nested, true
}
if !present {
return reportPageEvidence{}, reportResponseError(operation, "missing_pagination", "Report 列表缺少 hasMore,不能把当前页宣称为完整结果")
}
hasMore, ok := raw.(bool)
if !ok {
return reportPageEvidence{}, reportResponseError(operation, "malformed_pagination", "Report hasMore 应为布尔值")
}
page := reportPageEvidence{HasMore: hasMore}
next, nextPresent, err := reportNextCursor(data, result, operation)
if err != nil {
return reportPageEvidence{}, err
}
if !hasMore {
// The Report service echoes an integer cursor on terminal pages. It is a
// page receipt, not a continuation: hasMore=false is authoritative and
// the unified projection intentionally omits next_token. Parsing above
// still rejects wrong types and conflicting cursor fields.
return page, nil
}
if count == 0 {
return reportPageEvidence{}, reportResponseError(operation, "empty_page_with_continuation", "Report 空页仍声明 hasMore=true")
}
if !nextPresent || next <= 0 {
return reportPageEvidence{}, reportResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 continuation cursor")
}
page.Next = strconv.FormatInt(next, 10)
return page, nil
}
func reportNextCursor(data, result map[string]any, operation string) (int64, bool, error) {
var selected int64
found := false
for _, object := range []map[string]any{data, result} {
for _, key := range []string{"nextCursor", "cursor"} {
raw, present := object[key]
if !present || raw == nil {
continue
}
value, ok := reportInteger(raw)
if !ok {
return 0, false, reportResponseError(operation, "malformed_pagination", fmt.Sprintf("Report %s 应为整数", key))
}
if found && selected != value {
return 0, false, reportResponseError(operation, "conflicting_pagination", "Report continuation cursor 字段冲突")
}
selected, found = value, true
}
}
return selected, found, nil
}
func sameReportScalar(left, right any) bool {
leftJSON, leftErr := json.Marshal(left)
rightJSON, rightErr := json.Marshal(right)
return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON)
}
func reportProjectTemplates(data map[string]any, operation string) ([]map[string]any, error) {
if err := reportRequireSuccess(data, operation); err != nil {
return nil, err
}
raw, present := data["items"]
if !present {
return nil, reportResponseError(operation, "missing_collection", "Report 模板响应缺少 items 数组")
}
items, err := reportObjectCollection(raw, operation, "items")
if err != nil {
return nil, err
}
templates := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id, identityErr := reportRequiredString(item, operation, index, "report_template_id")
if identityErr != nil {
return nil, identityErr
}
name, nameErr := reportRequiredString(item, operation, index, "report_template_name")
if nameErr != nil {
return nil, nameErr
}
if _, duplicate := seen[id]; duplicate {
return nil, reportResponseError(operation, "duplicate_item_identity", "Report 模板响应包含重复 templateId")
}
seen[id] = struct{}{}
projected := map[string]any{"templateId": id, "name": name}
modified, present, modifiedErr := reportOptionalInteger(item, operation, index, "last_modified_time")
if modifiedErr != nil {
return nil, modifiedErr
}
if present {
projected["lastModifiedTime"] = modified
}
templates = append(templates, projected)
}
return templates, nil
}
func reportProjectDetail(data map[string]any, operation, expectedID string) (map[string]any, error) {
if err := reportRequireSuccess(data, operation); err != nil {
return nil, err
}
result, ok := data["result"].(map[string]any)
if !ok || len(result) == 0 {
return nil, reportResponseError(operation, "missing_result", "Report 详情成功响应缺少非空 result 对象")
}
id, err := reportRequiredString(result, operation, 0, "report_Id", "reportId", "report_id")
if err != nil {
return nil, err
}
if id != expectedID {
return nil, reportResponseError(operation, "identity_mismatch", "Report 详情 reportId 与请求目标不一致")
}
projected := map[string]any{"reportId": id}
for target, aliases := range map[string][]string{
"title": {"report_name", "reportName", "title"},
"templateName": {"report_template_name", "templateName"},
"creatorName": {"creatorName", "senderName"},
} {
value, valueErr := reportOptionalString(result, operation, 0, aliases...)
if valueErr != nil {
return nil, valueErr
}
if value != "" {
projected[target] = value
}
}
for target, aliases := range map[string][]string{
"createTime": {"createTime", "create_time"}, "modifiedTime": {"modifiedTime", "modified_time"},
} {
value, present, valueErr := reportOptionalInteger(result, operation, 0, aliases...)
if valueErr != nil {
return nil, valueErr
}
if present {
projected[target] = value
}
}
rawFields, present := result["report_content"]
if !present {
return nil, reportResponseError(operation, "missing_collection", "Report 详情缺少 result.report_content 数组")
}
fields, err := reportObjectCollection(rawFields, operation, "result.report_content")
if err != nil {
return nil, err
}
projectedFields := make([]map[string]any, 0, len(fields))
for index, field := range fields {
key, keyErr := reportRequiredString(field, operation, index, "key")
if keyErr != nil {
return nil, keyErr
}
rawValue, valuePresent := field["value"]
value, valueOK := rawValue.(string)
if !valuePresent || !valueOK {
return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 value 必须是字符串", index))
}
sortValue, sortOK := reportInteger(field["sort"])
typeValue, typeOK := reportInteger(field["type"])
if !sortOK || !typeOK {
return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 sort/type 必须是整数", index))
}
projectedFields = append(projectedFields, map[string]any{"key": key, "value": value, "sort": sortValue, "type": typeValue})
}
projected["fields"] = projectedFields
return projected, nil
}
func outputReportPage(rt *shortcut.RuntimeContext, collection string, entries []map[string]any, page reportPageEvidence) error {
payload := map[string]any{"count": len(entries), collection: entries, "complete": !page.HasMore}
if !output.UsesUnifiedResult(rt.Command()) {
if page.HasMore {
payload["nextCursor"] = page.Next
}
return rt.Output(payload)
}
pagination, err := output.NewPagination(!page.HasMore, page.Next)
if err != nil {
return reportResponseError("report/pagination", "invalid_pagination", err.Error())
}
meta := &output.Meta{Count: output.NewCount(len(entries)), Pagination: pagination}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package report
import (
"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"
)
var ReportLatest = shortcut.Shortcut{
Service: "report", Command: "+report-latest", Product: "report",
Description: "读取我最近提交的一篇日志详情",
Intent: "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+report-latest", "读取我最近提交的一篇日志详情",
"只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。",
reportLatestResult(), nil,
[]contract.ParamDecl{
{Name: "keyword", Property: "report_template_name"},
{Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"},
},
"dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`,
),
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"},
{Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"},
{Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"},
},
Tips: []string{"dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("start") != rt.Changed("end") {
return apperrors.NewValidation("--start 与 --end 必须同时提供", apperrors.WithReason("incomplete_creation_range"))
}
if rt.Changed("start") {
_, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays)
return err
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
const listOperation = "report/get_send_report_list"
now := time.Now()
end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
start := end.Add(-reportMaximumOutboxDays * 24 * time.Hour)
if rt.Changed("start") {
startMillis, endMillis, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays)
if err != nil {
return err
}
start, end = time.UnixMilli(startMillis), time.UnixMilli(endMillis)
}
params := map[string]any{
"cursor": 0, "size": 20,
"startTime": start.UnixMilli(), "endTime": end.UnixMilli(),
}
if keyword := strings.TrimSpace(rt.Str("keyword")); keyword != "" {
params["report_template_name"] = keyword
}
data, err := rt.CallMCPData("report", "get_send_report_list", params)
if err != nil {
return err
}
entries, page, err := reportProjectEntries(data, listOperation)
if err != nil {
return err
}
if page.HasMore {
return reportResponseError(listOperation, "incomplete_latest_candidates", "发件箱仍有后续页,不能从不完整集合宣称最新日志")
}
if len(entries) == 0 {
return apperrors.NewValidation("最近 20 天没有可验证的已发送日志", apperrors.WithReason("no_sent_report_fixture"))
}
latestID, err := reportLatestEntryID(entries, listOperation)
if err != nil {
return err
}
const detailOperation = "report/get_report_entry_details"
detailData, err := rt.CallMCPData("report", "get_report_entry_details", map[string]any{"report_id": latestID})
if err != nil {
return err
}
detail, err := reportProjectDetail(detailData, detailOperation, latestID)
if err != nil {
return err
}
return rt.Output(map[string]any{"report": detail})
},
}
func reportLatestEntryID(entries []map[string]any, operation string) (string, error) {
var latestID string
var latestTime int64
latestCount := 0
for index, entry := range entries {
created, ok := entry["createTime"].(int64)
if !ok || created <= 0 {
return "", reportResponseError(operation, "missing_latest_order", "发件箱项目缺少正整数 createTime,不能证明哪一篇最新")
}
id, ok := entry["reportId"].(string)
if !ok || strings.TrimSpace(id) == "" {
return "", reportResponseError(operation, "missing_item_identity", "发件箱项目缺少稳定 reportId")
}
if index == 0 || created > latestTime {
latestID, latestTime = id, created
latestCount = 1
} else if created == latestTime {
latestCount++
}
}
if latestCount != 1 {
return "", reportResponseError(operation, "ambiguous_latest_order", "多篇日志具有相同的最高 createTime,不能确定唯一最新日志")
}
return latestID, nil
}
func init() {
shortcut.Register(ReportLatest)
}
+230 -235
View File
@@ -1,103 +1,126 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Licensed under the Apache License, Version 2.0
// Package report declares declarative shortcuts for the DingTalk
// "报告/日志" (OA 周报应用) MCP tools. Each shortcut is a thin wrapper over a
// single MCP tool call; tool names and parameter keys mirror the DWS report
// helper (internal/helpers/report.go) verbatim.
// Package report registers strict declarative shortcuts for DingTalk reports.
package report
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// reportParseISOMillis parses an ISO-8601 timestamp (e.g. 2026-03-10T00:00:00+08:00)
// into Unix milliseconds, matching the helper's parseISOTimeToMillis behavior.
func reportParseISOMillis(name, s string) (int64, error) {
s = strings.TrimSpace(s)
t, err := time.Parse(time.RFC3339, s)
const (
reportMaximumInboxDays = 180
reportMaximumOutboxDays = 20
)
func reportParseISOMillis(name, value string) (int64, error) {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err)
return 0, apperrors.NewValidation(
fmt.Sprintf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00)", name),
apperrors.WithReason("invalid_time_format"),
)
}
return t.UnixMilli(), nil
return parsed.UnixMilli(), nil
}
func reportValidatePage(cursor, size int) error {
if cursor < 0 {
return apperrors.NewValidation("--cursor 不能小于 0", apperrors.WithReason("invalid_cursor"))
}
if size < 1 || size > 20 {
return apperrors.NewValidation("--size 必须在 1 到 20 之间", apperrors.WithReason("invalid_page_size"))
}
return nil
}
func reportValidateRange(startName, startValue, endName, endValue string, maximumDays int) (int64, int64, error) {
start, err := reportParseISOMillis(startName, startValue)
if err != nil {
return 0, 0, err
}
end, err := reportParseISOMillis(endName, endValue)
if err != nil {
return 0, 0, err
}
if end <= start {
return 0, 0, apperrors.NewValidation("结束时间必须晚于开始时间", apperrors.WithReason("invalid_time_range"))
}
if maximumDays > 0 && end-start > int64(maximumDays)*int64(24*time.Hour/time.Millisecond) {
return 0, 0, apperrors.NewValidation(
fmt.Sprintf("时间范围不能超过 %d 天", maximumDays),
apperrors.WithReason("time_range_too_large"),
)
}
return start, end, nil
}
func reportValidateContinuation(page reportPageEvidence, current int, operation string) error {
if !page.HasMore {
return nil
}
next, err := strconv.ParseInt(page.Next, 10, 64)
if err != nil || next <= int64(current) {
return reportResponseError(operation, "stalled_cursor", "Report continuation cursor 没有严格前进")
}
return nil
}
// TemplateList lists the report templates available to the current user.
// TemplateGet reads a single report template's field definitions by name.
// InboxList lists reports received by the current user within a time window.
var InboxList = shortcut.Shortcut{
Service: "report",
Command: "+inbox-list",
Product: "report",
Description: "列出我收到的日报(按时间范围分页)",
Intent: "当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "report",
Name: "shortcut_inbox_list",
CanonicalPath: "report.shortcut_inbox_list",
CLIPath: "report +inbox-list",
PrimaryCLIPath: "report +inbox-list",
Service: "report", Command: "+inbox-list", Product: "report",
Description: "列出我收到的日志",
Intent: "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+inbox-list", "列出我收到的日志",
"需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。",
reportCollectionResult("reports", "严格验证的收件箱日志页"), reportPagination(),
[]contract.ParamDecl{
{Name: "start", Property: "start"}, {Name: "end", Property: "end"},
{Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"},
{Name: "sender-user-ids", Property: "senderUserIds", InterfaceType: "array"},
},
Description: "列出我收到的日报(按时间范围分页)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "列出我收到的日报(按时间范围分页)",
UseWhen: []string{"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"},
},
},
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20",
),
Flags: []shortcut.Flag{
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)", Required: true},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)", Required: true},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"},
{Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表,逗号分隔,过滤指定发送人"},
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"},
{Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表"},
},
Tips: []string{
`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`,
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "结束时间必须晚于开始时间,跨度不得超过 180 天"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
startMs, err := reportParseISOMillis("start", rt.Str("start"))
if err != nil {
Tips: []string{`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`},
Validate: func(rt *shortcut.RuntimeContext) error {
if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil {
return err
}
endMs, err := reportParseISOMillis("end", rt.Str("end"))
_, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays)
return err
},
Execute: func(rt *shortcut.RuntimeContext) error {
const operation = "report/get_received_report_list"
start, end, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays)
if err != nil {
return err
}
params := map[string]any{
"startTime": startMs,
"endTime": endMs,
"cursor": rt.Int("cursor"),
"size": rt.Int("size"),
"startTime": start, "endTime": end,
"cursor": rt.Int("cursor"), "size": rt.Int("size"),
}
if rt.Changed("sender-user-ids") {
params["senderUserIds"] = rt.StrSlice("sender-user-ids")
@@ -106,192 +129,164 @@ var InboxList = shortcut.Shortcut{
if err != nil {
return err
}
reports := reportEntryListProject(data)
return rt.Output(map[string]any{"count": len(reports), "reports": reports})
reports, page, err := reportProjectEntries(data, operation)
if err != nil {
return err
}
if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil {
return err
}
return outputReportPage(rt, "reports", reports, page)
},
}
// reportEntryListProject reshapes the raw report-list responses
// (get_received_report_list / get_send_report_list) into a clean, stable list
// of report summaries — the output-projection fidelity the framework applies to
// every list command. Both the list container and the per-item field names are
// probed defensively across candidate keys so the projection tolerates
// response-shape drift and never fabricates data: an empty/unknown shape yields
// an empty list rather than a crash.
func reportEntryListProject(data map[string]any) []map[string]any {
raw := reportEntryListResolveList(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 := reportEntryListFirst(m, "reportId", "report_id", "id"); ok {
row["reportId"] = v
}
if v, ok := reportEntryListFirst(m, "templateName", "template_name", "reportTemplateName"); ok {
row["templateName"] = v
}
if v, ok := reportEntryListFirst(m, "creatorName", "creator_name", "creatorUserName", "senderName", "authorName"); ok {
row["creatorName"] = v
}
if v, ok := reportEntryListFirst(m, "creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"); ok {
row["creatorUserId"] = v
}
if v, ok := reportEntryListFirst(m, "createTime", "create_time", "gmtCreate", "sendTime"); ok {
row["createTime"] = v
}
if v, ok := reportEntryListFirst(m, "modifiedTime", "modified_time", "gmtModified", "modifyTime"); ok {
row["modifiedTime"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// reportEntryListResolveList locates the list payload inside the response,
// tolerating a bare top-level array or nesting one level under a common
// envelope key.
func reportEntryListResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// get_received_report_list / get_send_report_list nest the list under
// result.report_list (snake_case); "report_list" MUST be probed — the
// camelCase "reportList" alone leaves +inbox-list / +outbox-list silently
// empty despite the backend returning reports.
for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// reportEntryListFirst returns the first present candidate key's value.
func reportEntryListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// OutboxList lists reports sent/created by the current user.
var OutboxList = shortcut.Shortcut{
Service: "report",
Command: "+outbox-list",
Product: "report",
Description: "列出我发出的日报(可选时间/模版名过滤)",
Intent: "当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "report",
Name: "shortcut_outbox_list",
CanonicalPath: "report.shortcut_outbox_list",
CLIPath: "report +outbox-list",
PrimaryCLIPath: "report +outbox-list",
Service: "report", Command: "+outbox-list", Product: "report",
Description: "列出我发出的日志",
Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+outbox-list", "列出我发出的日志",
"需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。",
reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(),
[]contract.ParamDecl{
{Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"},
{Name: "start", Property: "start"}, {Name: "end", Property: "end"},
{Name: "modified-start", Property: "modifiedStart"}, {Name: "modified-end", Property: "modifiedEnd"},
{Name: "template-name", Property: "templateName"},
},
Description: "列出我发出的日报(可选时间/模版名过滤)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "列出我发出的日报(可选时间/模版名过滤)",
UseWhen: []string{"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws report +outbox-list --cursor 0 --size 20",
"dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\"",
},
},
},
"dws report +outbox-list --cursor 0 --size 20",
),
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"},
{Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601 (可选,服务端单次跨度上限 20 天)"},
{Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601 (可选)"},
{Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601 (可选)"},
{Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601 (可选)"},
{Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称 (可选,不传查全部)"},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"},
{Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"},
{Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"},
{Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"},
{Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"},
{Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称"},
},
Tips: []string{
`dws report +outbox-list --cursor 0 --size 20`,
`dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`,
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "创建时间范围必须有效且不得超过 20 天"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"modified-start", "modified-end"}, Description: "修改时间必须成对提供、范围有效且不得超过 20 天"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"cursor": rt.Int("cursor"),
"size": rt.Int("size"),
Tips: []string{"dws report +outbox-list --cursor 0 --size 20"},
Validate: func(rt *shortcut.RuntimeContext) error {
if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil {
return err
}
if rt.Changed("start") {
ms, err := reportParseISOMillis("start", rt.Str("start"))
if err != nil {
if rt.Changed("start") && rt.Changed("end") {
if _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays); err != nil {
return err
}
params["startTime"] = ms
}
if rt.Changed("end") {
ms, err := reportParseISOMillis("end", rt.Str("end"))
if err != nil {
return err
}
params["endTime"] = ms
if rt.Changed("modified-start") != rt.Changed("modified-end") {
return apperrors.NewValidation("--modified-start 与 --modified-end 必须同时提供", apperrors.WithReason("incomplete_modified_range"))
}
if rt.Changed("modified-start") {
ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start"))
if err != nil {
return err
}
params["modifiedStartTime"] = ms
_, _, err := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays)
return err
}
if rt.Changed("modified-end") {
ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end"))
if err != nil {
return err
}
params["modifiedEndTime"] = ms
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
const operation = "report/get_send_report_list"
now := time.Now()
defaultEnd := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
startValue := defaultEnd.Add(-reportMaximumOutboxDays * 24 * time.Hour).Format(time.RFC3339)
endValue := defaultEnd.Format(time.RFC3339)
if rt.Changed("start") {
startValue = rt.Str("start")
}
if rt.Changed("template-name") {
params["report_template_name"] = rt.Str("template-name")
if rt.Changed("end") {
endValue = rt.Str("end")
}
start, end, err := reportValidateRange("start", startValue, "end", endValue, reportMaximumOutboxDays)
if err != nil {
return err
}
params := map[string]any{
"cursor": rt.Int("cursor"), "size": rt.Int("size"),
"startTime": start, "endTime": end,
}
if rt.Changed("modified-start") {
modifiedStart, modifiedEnd, rangeErr := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays)
if rangeErr != nil {
return rangeErr
}
params["modifiedStartTime"], params["modifiedEndTime"] = modifiedStart, modifiedEnd
}
if name := strings.TrimSpace(rt.Str("template-name")); name != "" {
params["report_template_name"] = name
}
data, err := rt.CallMCPData("report", "get_send_report_list", params)
if err != nil {
return err
}
reports := reportEntryListProject(data)
return rt.Output(map[string]any{"count": len(reports), "reports": reports})
reports, page, err := reportProjectEntries(data, operation)
if err != nil {
return err
}
if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil {
return err
}
return outputReportPage(rt, "reports", reports, page)
},
}
// EntryGet reads the full body of a single report by id.
// EntryStats reads the read-receipt statistics of a single report.
// EntrySubmit submits a new report against a template.
var TemplateSearch = shortcut.Shortcut{
Service: "report", Command: "+template-search", Product: "report",
Description: "按名称搜索可用日志模板",
Intent: "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。",
Risk: shortcut.RiskRead,
Safety: reportReadSafety(),
OutputRollout: output.RolloutUnifiedActive,
Contract: reportContract(
"+template-search", "按名称搜索可用日志模板",
"需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。",
reportTemplateSearchResult(), nil,
[]contract.ParamDecl{{Name: "query", Property: "report_template_name"}},
"dws report +template-search --query 周报",
),
Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "模板名称关键词,不区分大小写;--query 不能为空", Required: true}},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空"}},
Tips: []string{"dws report +template-search --query 周报"},
Validate: func(rt *shortcut.RuntimeContext) error {
if strings.TrimSpace(rt.Str("query")) == "" {
return apperrors.NewValidation("--query 不能为空", apperrors.WithReason("empty_query"))
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
const operation = "report/get_available_report_templates"
data, err := rt.CallMCPData("report", "get_available_report_templates", map[string]any{})
if err != nil {
return err
}
templates, err := reportProjectTemplates(data, operation)
if err != nil {
return err
}
query := strings.ToLower(strings.TrimSpace(rt.Str("query")))
matches := make([]map[string]any, 0)
for _, template := range templates {
if strings.Contains(strings.ToLower(template["name"].(string)), query) {
matches = append(matches, template)
}
}
payload := map[string]any{"count": len(matches), "templates": matches}
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
meta := &output.Meta{Count: output.NewCount(len(matches))}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
},
}
func init() {
shortcut.Register(
InboxList,
OutboxList,
)
shortcut.Register(InboxList, OutboxList, TemplateSearch)
}
@@ -1,38 +0,0 @@
// 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 report
import (
"encoding/json"
"testing"
)
// TestReportEntryListResolveListSnakeShape guards against projection-data-loss:
// get_received_report_list / get_send_report_list nest the list under
// result.report_list (snake_case); the resolver must probe "report_list" or
// +inbox-list / +outbox-list silently return empty despite the backend having
// reports.
func TestReportEntryListResolveListSnakeShape(t *testing.T) {
const raw = `{"result":{"report_list":[
{"reportId":"r1","templateName":"daily report"},
{"reportId":"r2","templateName":"weekly report"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := reportEntryListResolveList(data); len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.report_list has 2 entries, resolver returned %d", len(got))
}
}
@@ -0,0 +1,625 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package report
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"math"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type reportCoverageCall struct {
tool string
args map[string]any
}
type reportCoverageCaller struct {
responses map[string][]string
history []reportCoverageCall
}
func (caller *reportCoverageCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
caller.history = append(caller.history, reportCoverageCall{tool: tool, args: args})
queue := caller.responses[tool]
if len(queue) == 0 {
return nil, errors.New("missing Report fake response for " + tool)
}
caller.responses[tool] = queue[1:]
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
}
func (*reportCoverageCaller) Format() string { return "json" }
func (*reportCoverageCaller) DryRun() bool { return false }
func (*reportCoverageCaller) Fields() string { return "" }
func (*reportCoverageCaller) JQ() string { return "" }
func runReportCoverage(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) (*cobra.Command, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd, cmd.Execute()
}
func directReportRuntime(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) *shortcut.RuntimeContext {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Flags().Parse(args); err != nil {
t.Fatal(err)
}
return shortcut.RuntimeContextForTest(cmd, declaration)
}
func TestCrossPlatformCoverageReportContractsAreStrictTypedAndUnified(t *testing.T) {
declarations := []shortcut.Shortcut{InboxList, OutboxList, TemplateSearch, ReportLatest}
for _, declaration := range declarations {
if declaration.Contract.Empty() || declaration.Contract.Result == nil {
t.Errorf("%s lacks Contract/Result", declaration.Command)
}
if declaration.Safety.Effect != "read" || declaration.Safety.Confirmation != "not_required" {
t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety)
}
if declaration.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
}
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface)
}
}
if InboxList.Contract.Pagination == nil || OutboxList.Contract.Pagination == nil {
t.Fatal("Report list shortcuts must publish cursor pagination")
}
if TemplateSearch.Contract.Pagination != nil || ReportLatest.Contract.Pagination != nil {
t.Fatal("non-paginated Report shortcuts published pagination")
}
}
func TestCrossPlatformCoverageReportListResponseMatrix(t *testing.T) {
valid := map[string]any{
"success": true,
"result": map[string]any{
"report_list": []any{map[string]any{"reportId": "report-1", "templateName": "fixture", "createTime": float64(10)}},
},
"hasMore": false,
}
entries, page, err := reportProjectEntries(valid, "report/get_received_report_list")
if err != nil || len(entries) != 1 || page.HasMore || entries[0]["createTime"] != int64(10) {
t.Fatalf("valid projection entries=%#v page=%+v err=%v", entries, page, err)
}
empty := map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "cursor": nil}
if entries, page, err := reportProjectEntries(empty, "report/get_received_report_list"); err != nil || len(entries) != 0 || page.HasMore {
t.Fatalf("terminal empty entries=%#v page=%+v err=%v", entries, page, err)
}
terminalReceipt := map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": false, "nextCursor": float64(1)}
if entries, page, err := reportProjectEntries(terminalReceipt, "report/get_received_report_list"); err != nil || len(entries) != 1 || page.HasMore || page.Next != "" {
t.Fatalf("terminal cursor receipt entries=%#v page=%+v err=%v", entries, page, err)
}
fixtures := map[string]map[string]any{
"empty response": map[string]any{},
"missing success": map[string]any{"result": map[string]any{"report_list": []any{}}, "hasMore": false},
"wrong success": map[string]any{"success": "true", "result": map[string]any{"report_list": []any{}}, "hasMore": false},
"remote failure": map[string]any{"success": false, "result": map[string]any{"report_list": []any{}}, "hasMore": false},
"missing result": map[string]any{"success": true, "hasMore": false},
"wrong result": map[string]any{"success": true, "result": "bad", "hasMore": false},
"missing collection": map[string]any{"success": true, "result": map[string]any{}, "hasMore": false},
"wrong collection": map[string]any{"success": true, "result": map[string]any{"report_list": map[string]any{}}, "hasMore": false},
"bad item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{"bad"}}, "hasMore": false},
"empty item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{}}}, "hasMore": false},
"missing identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"createTime": float64(1)}}}, "hasMore": false},
"wrong identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": float64(1)}}}, "hasMore": false},
"duplicate identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "same"}, map[string]any{"reportId": "same"}}}, "hasMore": false},
"wrong optional": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "createTime": "1"}}}, "hasMore": false},
"missing pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}},
"wrong pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": "false"},
"empty continuation": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": true, "nextCursor": float64(2)},
"missing continuation": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true,
},
"wrong continuation": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, "nextCursor": "2",
},
"wrong terminal cursor": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "nextCursor": "2",
},
"conflicting has more": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{}, "hasMore": true}, "hasMore": false,
},
"conflicting continuation": map[string]any{
"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}, "cursor": float64(3)}, "hasMore": true, "nextCursor": float64(2),
},
}
for name, fixture := range fixtures {
if projected, _, projectErr := reportProjectEntries(fixture, "report/get_received_report_list"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "2"}, 2, "report/list"); err == nil {
t.Fatal("stalled continuation returned success")
}
}
func TestCrossPlatformCoverageReportTemplateResponseMatrix(t *testing.T) {
valid := map[string]any{"success": true, "items": []any{
map[string]any{"report_template_id": "template-1", "report_template_name": "Fixture Weekly", "last_modified_time": float64(2)},
map[string]any{"report_template_id": "template-2", "report_template_name": "Fixture Daily"},
}}
templates, err := reportProjectTemplates(valid, "report/get_available_report_templates")
if err != nil || len(templates) != 2 || templates[0]["lastModifiedTime"] != int64(2) {
t.Fatalf("valid templates=%#v err=%v", templates, err)
}
for name, fixture := range map[string]map[string]any{
"empty response": {},
"missing success": {"items": []any{}},
"wrong success": {"success": float64(1), "items": []any{}},
"missing collection": {"success": true},
"wrong collection": {"success": true, "items": map[string]any{}},
"bad item": {"success": true, "items": []any{"bad"}},
"missing id": {"success": true, "items": []any{map[string]any{"report_template_name": "fixture"}}},
"missing name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1"}}},
"wrong name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": float64(1)}}},
"duplicate id": {"success": true, "items": []any{map[string]any{"report_template_id": "same", "report_template_name": "one"}, map[string]any{"report_template_id": "same", "report_template_name": "two"}}},
"wrong modified": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": "fixture", "last_modified_time": "2"}}},
} {
if projected, projectErr := reportProjectTemplates(fixture, "report/get_available_report_templates"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
if projected, err := reportProjectTemplates(map[string]any{"success": true, "items": []any{}}, "report/templates"); err != nil || len(projected) != 0 {
t.Fatalf("explicit empty template collection=%#v err=%v", projected, err)
}
}
func TestCrossPlatformCoverageReportDetailResponseMatrix(t *testing.T) {
valid := map[string]any{"success": true, "result": map[string]any{
"report_Id": "report-2", "report_name": "fixture", "createTime": float64(2),
"report_content": []any{map[string]any{"key": "field", "value": "value", "sort": float64(1), "type": float64(2)}},
}}
detail, err := reportProjectDetail(valid, "report/get_report_entry_details", "report-2")
if err != nil || detail["reportId"] != "report-2" || len(detail["fields"].([]map[string]any)) != 1 {
t.Fatalf("valid detail=%#v err=%v", detail, err)
}
for name, fixture := range map[string]map[string]any{
"empty response": {},
"missing result": {"success": true},
"empty result": {"success": true, "result": map[string]any{}},
"missing id": {"success": true, "result": map[string]any{"report_content": []any{}}},
"identity mismatch": {"success": true, "result": map[string]any{"report_Id": "other", "report_content": []any{}}},
"missing collection": {"success": true, "result": map[string]any{"report_Id": "report-2"}},
"wrong collection": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": map[string]any{}}},
"bad field": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{"bad"}}},
"missing field key": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"value": "value", "sort": float64(1), "type": float64(2)}}}},
"wrong field type": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": "1", "type": float64(2)}}}},
} {
if projected, projectErr := reportProjectDetail(fixture, "report/get_report_entry_details", "report-2"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
}
func TestCrossPlatformCoverageReportExactShortcutsProjectUnifiedData(t *testing.T) {
templateCaller := &reportCoverageCaller{responses: map[string][]string{
"get_available_report_templates": {`{"success":true,"items":[{"report_template_id":"template-1","report_template_name":"Fixture Weekly","last_modified_time":2},{"report_template_id":"template-2","report_template_name":"Fixture Daily"}]}`},
}}
cmd, err := runReportCoverage(t, TemplateSearch, templateCaller, "--query", "weekly")
if err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
cmd.SetOut(&stdout)
if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 {
t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr)
}
var envelope map[string]any
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
data := envelope["data"].(map[string]any)
if data["count"] != float64(1) || len(data["templates"].([]any)) != 1 {
t.Fatalf("template search data=%#v", data)
}
if _, leaked := data["items"]; leaked {
t.Fatalf("template search leaked raw collection: %#v", data)
}
inboxCaller := &reportCoverageCaller{responses: map[string][]string{
"get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false,"cursor":null}`},
}}
cmd, err = runReportCoverage(t, InboxList, inboxCaller,
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00")
if err != nil {
t.Fatal(err)
}
stdout.Reset()
cmd.SetOut(&stdout)
if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted {
t.Fatalf("inbox emit=(%t,%v)", emitted, emitErr)
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
data = envelope["data"].(map[string]any)
if data["count"] != float64(1) || data["complete"] != true {
t.Fatalf("inbox data=%#v", data)
}
}
func TestCrossPlatformCoverageReportLatestRequiresCompleteOrderedListAndExactReadback(t *testing.T) {
missingOrder := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":false}`},
}}
if _, err := runReportCoverage(t, ReportLatest, missingOrder); err == nil || len(missingOrder.history) != 1 {
t.Fatalf("missing order err=%v history=%v", err, missingOrder.history)
}
incomplete := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":true,"nextCursor":20}`},
}}
if _, err := runReportCoverage(t, ReportLatest, incomplete); err == nil || len(incomplete.history) != 1 {
t.Fatalf("incomplete err=%v history=%v", err, incomplete.history)
}
tied := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":2},{"reportId":"report-2","createTime":2}]},"hasMore":false}`},
}}
if _, err := runReportCoverage(t, ReportLatest, tied); err == nil || len(tied.history) != 1 {
t.Fatalf("tied latest err=%v history=%v", err, tied.history)
}
caller := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1},{"reportId":"report-2","createTime":2}]},"hasMore":false}`},
"get_report_entry_details": {`{"success":true,"result":{"report_Id":"report-2","report_name":"fixture","createTime":2,"report_content":[{"key":"field","value":"value","sort":1,"type":2}]}}`},
}}
cmd, err := runReportCoverage(t, ReportLatest, caller,
"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-20T00:00:00+08:00")
if err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[0].tool != "get_send_report_list" || caller.history[1].tool != "get_report_entry_details" || caller.history[1].args["report_id"] != "report-2" {
t.Fatalf("exact call history=%#v", caller.history)
}
if caller.history[0].args["startTime"] != int64(1782835200000) || caller.history[0].args["endTime"] != int64(1784476800000) {
t.Fatalf("explicit range args=%#v", caller.history[0].args)
}
var stdout bytes.Buffer
cmd.SetOut(&stdout)
if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted {
t.Fatalf("latest emit=(%t,%v)", emitted, emitErr)
}
var envelope map[string]any
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
projected := envelope["data"].(map[string]any)["report"].(map[string]any)
_, leaked := projected["success"]
if projected["reportId"] != "report-2" || leaked {
t.Fatalf("latest projection=%s", stdout.String())
}
}
func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *testing.T) {
tests := []struct {
declaration shortcut.Shortcut
args []string
}{
{InboxList, []string{"--start", "2026-07-02T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}},
{InboxList, []string{"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00", "--size", "21"}},
{OutboxList, []string{"--modified-start", "2026-07-01T00:00:00+08:00"}},
{ReportLatest, []string{"--start", "2026-07-01T00:00:00+08:00"}},
{ReportLatest, []string{"--start", "2026-07-22T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}},
}
for _, test := range tests {
caller := &reportCoverageCaller{responses: map[string][]string{}}
if _, err := runReportCoverage(t, test.declaration, caller, test.args...); err == nil || len(caller.history) != 0 {
t.Errorf("%s args=%v err=%v calls=%v", test.declaration.Command, test.args, err, caller.history)
}
}
}
func TestCrossPlatformCoverageReportPrimitiveStrictnessAndProjectionBranches(t *testing.T) {
operation := "report/coverage"
if err := reportRequireSuccess(map[string]any{"success": false, "errorMessage": " rejected "}, operation); err == nil {
t.Fatal("remote failure with message returned success")
}
arrayEntries, container, err := reportListCollection(map[string]any{
"success": true,
"result": []any{map[string]any{"report_id": "report-array"}},
"hasMore": false,
}, operation)
if err != nil || len(arrayEntries) != 1 || container["hasMore"] != false {
t.Fatalf("array collection entries=%#v container=%#v err=%v", arrayEntries, container, err)
}
if _, err := reportRequiredString(map[string]any{"reportId": "one", "report_id": "two"}, operation, 0, "reportId", "report_id"); err == nil {
t.Fatal("conflicting aliases returned success")
}
if _, err := reportOptionalString(map[string]any{"name": 1}, operation, 0, "name"); err == nil {
t.Fatal("non-string optional value returned success")
}
integerCases := []struct {
value any
want int64
ok bool
}{
{float64(7), 7, true}, {math.NaN(), 0, false}, {math.Inf(1), 0, false},
{1.5, 0, false}, {int(8), 8, true}, {int64(9), 9, true},
{json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {"11", 0, false},
}
for _, test := range integerCases {
got, ok := reportInteger(test.value)
if got != test.want || ok != test.ok {
t.Errorf("reportInteger(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok)
}
}
fullEntry := map[string]any{
"success": true,
"result": map[string]any{
"hasMore": true,
"nextCursor": json.Number("2"),
"report_list": []any{map[string]any{
"report_id": "report-full", "report_template_name": "Weekly",
"senderName": "sender", "senderUserId": "user", "gmtCreate": int(12), "modifyTime": int64(13),
}},
},
}
entries, page, err := reportProjectEntries(fullEntry, operation)
if err != nil || len(entries) != 1 || !page.HasMore || page.Next != "2" || entries[0]["modifiedTime"] != int64(13) {
t.Fatalf("full entry entries=%#v page=%+v err=%v", entries, page, err)
}
if err := reportValidateContinuation(page, 1, operation); err != nil {
t.Fatalf("advancing continuation: %v", err)
}
if err := reportValidateContinuation(reportPageEvidence{}, 0, operation); err != nil {
t.Fatalf("terminal continuation: %v", err)
}
if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "bad"}, 0, operation); err == nil {
t.Fatal("malformed continuation returned success")
}
badOptional := map[string]any{
"success": true, "hasMore": false,
"result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "senderName": 1}}},
}
if _, _, err := reportProjectEntries(badOptional, operation); err == nil {
t.Fatal("bad optional entry field returned success")
}
completeDetail := map[string]any{"success": true, "result": map[string]any{
"reportId": "report-detail", "reportName": "Title", "templateName": "Template", "senderName": "Creator",
"create_time": int(4), "modified_time": json.Number("5"),
"report_content": []any{map[string]any{"key": "field", "value": "", "sort": int(1), "type": int64(2)}},
}}
detail, err := reportProjectDetail(completeDetail, operation, "report-detail")
if err != nil || detail["modifiedTime"] != int64(5) || len(detail["fields"].([]map[string]any)) != 1 {
t.Fatalf("complete detail=%#v err=%v", detail, err)
}
for name, fixture := range map[string]map[string]any{
"bad optional string": {"success": true, "result": map[string]any{"reportId": "report-detail", "reportName": 1, "report_content": []any{}}},
"bad optional integer": {"success": true, "result": map[string]any{"reportId": "report-detail", "create_time": "4", "report_content": []any{}}},
"missing field value": {"success": true, "result": map[string]any{"reportId": "report-detail", "report_content": []any{map[string]any{"key": "field", "sort": 1, "type": 2}}}},
} {
if projected, projectErr := reportProjectDetail(fixture, operation, "report-detail"); projectErr == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
}
func TestCrossPlatformCoverageReportValidationHelpersCoverAllContracts(t *testing.T) {
if _, err := reportParseISOMillis("start", "not-a-time"); err == nil {
t.Fatal("invalid ISO timestamp returned success")
}
if err := reportValidatePage(-1, 20); err == nil {
t.Fatal("negative cursor returned success")
}
if err := reportValidatePage(0, 0); err == nil {
t.Fatal("zero page size returned success")
}
if err := reportValidatePage(0, 20); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name, start, end string
maximumDays int
}{
{"bad start", "bad", "2026-07-02T00:00:00+08:00", 20},
{"bad end", "2026-07-01T00:00:00+08:00", "bad", 20},
{"too wide", "2026-07-01T00:00:00+08:00", "2026-08-01T00:00:00+08:00", 20},
} {
if _, _, err := reportValidateRange("start", test.start, "end", test.end, test.maximumDays); err == nil {
t.Errorf("%s returned success", test.name)
}
}
if _, _, err := reportValidateRange("start", "2026-07-01T00:00:00+08:00", "end", "2026-08-01T00:00:00+08:00", 0); err != nil {
t.Fatalf("unbounded valid range: %v", err)
}
}
func TestCrossPlatformCoverageReportExecutorsSuccessErrorsAndZeroCalls(t *testing.T) {
start := "2026-07-01T00:00:00+08:00"
end := "2026-07-02T00:00:00+08:00"
validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]} ,"hasMore":false}`
inbox := &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {validList}}}
if _, err := runReportCoverage(t, InboxList, inbox, "--start", start, "--end", end, "--sender-user-ids", "user-1,user-2"); err != nil {
t.Fatal(err)
}
if len(inbox.history) != 1 || len(inbox.history[0].args["senderUserIds"].([]string)) != 2 {
t.Fatalf("inbox args/history=%#v", inbox.history)
}
for _, test := range []struct {
name string
caller *reportCoverageCaller
args []string
}{
{"call error", &reportCoverageCaller{responses: map[string][]string{}}, []string{"--start", start, "--end", end}},
{"projection error", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true}`}}}, []string{"--start", start, "--end", end}},
{"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--start", start, "--end", end, "--cursor", "2"}},
} {
if _, err := runReportCoverage(t, InboxList, test.caller, test.args...); err == nil {
t.Errorf("inbox %s returned success", test.name)
}
}
directInbox := directReportRuntime(t, InboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end)
if err := InboxList.Execute(directInbox); err == nil {
t.Fatal("direct inbox invalid range returned success")
}
outboxAll := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-2","createTime":2}]},"hasMore":true,"nextCursor":3}`}}}
if _, err := runReportCoverage(t, OutboxList, outboxAll,
"--cursor", "2", "--start", start, "--end", end,
"--modified-start", start, "--modified-end", end, "--template-name", " Weekly "); err != nil {
t.Fatal(err)
}
if len(outboxAll.history) != 1 || outboxAll.history[0].args["report_template_name"] != "Weekly" || outboxAll.history[0].args["modifiedStartTime"] == nil {
t.Fatalf("outbox args/history=%#v", outboxAll.history)
}
defaultOutbox := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {validList}}}
if _, err := runReportCoverage(t, OutboxList, defaultOutbox); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
caller *reportCoverageCaller
args []string
}{
{"call error", &reportCoverageCaller{responses: map[string][]string{}}, nil},
{"projection error", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true}`}}}, nil},
{"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--cursor", "2"}},
} {
if _, err := runReportCoverage(t, OutboxList, test.caller, test.args...); err == nil {
t.Errorf("outbox %s returned success", test.name)
}
}
directOutbox := directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end)
if err := OutboxList.Execute(directOutbox); err == nil {
t.Fatal("direct outbox bad creation range returned success")
}
directOutbox = directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}},
"--modified-start", "bad", "--modified-end", end)
if err := OutboxList.Execute(directOutbox); err == nil {
t.Fatal("direct outbox bad modified range returned success")
}
for _, test := range []struct {
name string
declaration shortcut.Shortcut
args []string
}{
{"outbox invalid page", OutboxList, []string{"--size", "0"}},
{"outbox invalid creation range", OutboxList, []string{"--start", "bad", "--end", end}},
{"outbox valid modified range", OutboxList, []string{"--modified-start", start, "--modified-end", end}},
{"template empty query", TemplateSearch, []string{"--query", " "}},
} {
caller := &reportCoverageCaller{responses: map[string][]string{}}
_, err := runReportCoverage(t, test.declaration, caller, test.args...)
if test.name == "outbox valid modified range" {
if err == nil || len(caller.history) != 1 {
t.Errorf("%s err=%v history=%#v", test.name, err, caller.history)
}
continue
}
if err == nil || len(caller.history) != 0 {
t.Errorf("%s err=%v history=%#v", test.name, err, caller.history)
}
}
for name, caller := range map[string]*reportCoverageCaller{
"call error": {responses: map[string][]string{}},
"projection error": {responses: map[string][]string{"get_available_report_templates": {`{"success":true}`}}},
} {
if _, err := runReportCoverage(t, TemplateSearch, caller, "--query", "weekly"); err == nil {
t.Errorf("template %s returned success", name)
}
}
directTemplate := directReportRuntime(t, TemplateSearch, &reportCoverageCaller{responses: map[string][]string{}}, "--query", " ")
if err := TemplateSearch.Validate(directTemplate); err == nil {
t.Fatal("direct template whitespace query returned success")
}
legacyTemplate := TemplateSearch
legacyTemplate.OutputRollout = output.RolloutLegacyOnly
legacyTemplateCaller := &reportCoverageCaller{responses: map[string][]string{"get_available_report_templates": {`{"success":true,"items":[]}`}}}
if _, err := runReportCoverage(t, legacyTemplate, legacyTemplateCaller, "--query", "zero"); err != nil {
t.Fatalf("legacy template: %v", err)
}
}
func TestCrossPlatformCoverageReportLatestExecutorRemainingBranches(t *testing.T) {
validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false}`
validDetail := `{"success":true,"result":{"report_Id":"report-1","report_content":[]}}`
defaultCaller := &reportCoverageCaller{responses: map[string][]string{
"get_send_report_list": {validList}, "get_report_entry_details": {validDetail},
}}
if _, err := runReportCoverage(t, ReportLatest, defaultCaller, "--keyword", " Weekly "); err != nil {
t.Fatal(err)
}
if defaultCaller.history[0].args["report_template_name"] != "Weekly" {
t.Fatalf("latest keyword args=%#v", defaultCaller.history[0].args)
}
for name, caller := range map[string]*reportCoverageCaller{
"list call error": {responses: map[string][]string{}},
"list projection error": {responses: map[string][]string{
"get_send_report_list": {`{"success":true}`},
}},
"empty candidates": {responses: map[string][]string{
"get_send_report_list": {`{"success":true,"result":{"report_list":[]},"hasMore":false}`},
}},
"detail call error": {responses: map[string][]string{
"get_send_report_list": {validList},
}},
"detail projection error": {responses: map[string][]string{
"get_send_report_list": {validList}, "get_report_entry_details": {`{"success":true}`},
}},
} {
if _, err := runReportCoverage(t, ReportLatest, caller); err == nil {
t.Errorf("latest %s returned success", name)
}
}
directLatest := directReportRuntime(t, ReportLatest, &reportCoverageCaller{responses: map[string][]string{}},
"--start", "bad", "--end", "2026-07-02T00:00:00+08:00")
if err := ReportLatest.Execute(directLatest); err == nil {
t.Fatal("direct latest invalid range returned success")
}
if _, err := reportLatestEntryID([]map[string]any{{"createTime": int64(1), "reportId": 1}}, "report/latest"); err == nil {
t.Fatal("non-string latest report identity returned success")
}
}
func TestCrossPlatformCoverageReportOutputLegacyAndInvalidPagination(t *testing.T) {
command := &cobra.Command{Use: "report-page"}
command.SetOut(io.Discard)
command.SetContext(context.Background())
declaration := shortcut.Shortcut{Service: "report", Product: "report"}
rt := shortcut.RuntimeContextForTest(command, declaration)
output.SetCommandRollout(command, output.RolloutLegacyOnly)
if err := outputReportPage(rt, "reports", []map[string]any{{"reportId": "report-1"}}, reportPageEvidence{HasMore: true, Next: "2"}); err != nil {
t.Fatalf("legacy report page: %v", err)
}
ctx, _ := output.WithResultStore(context.Background())
command.SetContext(ctx)
output.SetCommandRollout(command, output.RolloutUnifiedActive)
if err := outputReportPage(rt, "reports", nil, reportPageEvidence{HasMore: true}); err == nil {
t.Fatal("unified page without continuation returned success")
}
}
+18 -5
View File
@@ -40,6 +40,15 @@ var attendanceSemanticCatalogJSON []byte
//go:embed semantic_catalog_mail.json
var mailSemanticCatalogJSON []byte
//go:embed semantic_catalog_oa.json
var oaSemanticCatalogJSON []byte
//go:embed semantic_catalog_ding.json
var dingSemanticCatalogJSON []byte
//go:embed semantic_catalog_report.json
var reportSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -69,6 +78,9 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
todoSemanticCatalogJSON,
attendanceSemanticCatalogJSON,
mailSemanticCatalogJSON,
oaSemanticCatalogJSON,
dingSemanticCatalogJSON,
reportSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -130,11 +142,12 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) {
command, record.Availability))
}
// A command that was already part of the visible CLI contract cannot be
// hidden in the same feature change merely because Agent publication is
// withdrawn. This narrow fact preserves historical discovery/argv while
// the unavailable Interface and public=false keep it out of Agent routes.
if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) {
panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command))
// hidden merely because Agent publication is withdrawn. Compatibility
// visibility owns only historical CLI discovery; availability independently
// records whether that compatibility path still executes. public=false keeps
// both available and unavailable compatibility leaves out of Agent routes.
if record.CompatibilityVisible && record.Public {
panic(fmt.Sprintf("semantic catalog command %q cannot be both public and compatibility-visible", command))
}
key := publicCatalogKey(source.Service, command)
if _, exists := out[key]; exists {
@@ -136,21 +136,6 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T)
}
}
}`,
"compatibility-visible available": `{
"version": 1,
"service": "chat",
"default_availability": "available",
"shortcuts": {
"+messages": {
"disposition": "semantic_adapter",
"semantic_delta": "reviewed",
"risk": "read",
"public": false,
"compatibility_visible": true,
"reviewed": true
}
}
}`,
}
original := semanticCatalogJSON
t.Cleanup(func() { semanticCatalogJSON = original })
@@ -210,6 +195,28 @@ func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) {
if InPublicCatalog(item.Service, item.Command) {
t.Fatal("compatibility-visible unavailable shortcut entered the public catalog")
}
available := map[string]semanticCatalogRecord{}
loadSemanticCatalog([]byte(`{
"version":1,
"service":"compatibility-test",
"default_availability":"unavailable",
"shortcuts":{
"+legacy":{
"disposition":"semantic_adapter",
"semantic_delta":"historical CLI path remains executable but is not Agent-public",
"risk":"write",
"availability":"available",
"public":false,
"compatibility_visible":true,
"reviewed":true
}
}
}`), available)
record := available[publicCatalogKey("compatibility-test", "+legacy")]
if record.Public || !record.CompatibilityVisible || record.Availability != AvailabilityAvailable {
t.Fatalf("available compatibility record = %#v", record)
}
}
func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) {
@@ -0,0 +1,48 @@
{
"version": 1,
"service": "ding",
"default_availability": "unavailable",
"shortcuts": {
"+list": {
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result.dingMessages、稳定 openDingId 与 hasMore/nextCursor;exact Shortcut 已完成已知非空与保证零项证明,但真实续页返回停滞 cursor,严格实现拒绝虚假分页并保持不可用。",
"risk": "read",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+receiver-status": {
"disposition": "semantic_adapter",
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+send-personal": {
"disposition": "semantic_adapter",
"semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行,并修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;因接收项没有稳定接收人身份且撤回无可查询终态,不进入 Agent 公共发现。",
"risk": "write",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+send-by-message": {
"disposition": "semantic_adapter",
"semantic_delta": "覆盖基于消息的应用内、短信和电话 DING并映射 lark-cli 三种 urgent 任务;隔离 APP fixture 已证明源消息、稳定 DING 回执和目标 DING 读回,但接收人身份与撤回终态仍不可验证,保持不可用。",
"risk": "write",
"public": false,
"reviewed": true
},
"+recall-personal": {
"disposition": "semantic_adapter",
"semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行;个人 DING 撤回只返回布尔成功且不回显终态,不能作为 Agent 可验证写能力,因此不进入公共发现。",
"risk": "high-risk-write",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
}
}
}
@@ -0,0 +1,96 @@
{
"version": 1,
"service": "oa",
"default_availability": "unavailable",
"shortcuts": {
"+list-executed": {
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+list-submitted": {
"disposition": "semantic_adapter",
"semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+my-initiated": {
"disposition": "alias_internal",
"semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持非公开。",
"risk": "read",
"availability": "available",
"primary": "+list-submitted",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+search-forms": {
"disposition": "semantic_adapter",
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+done-approvals": {
"disposition": "alias_internal",
"semantic_delta": "保留旧的首屏已处理审批摘要;已验证严格非空结果,但不提供页码,新的 Agent 路由统一使用 +list-executed。",
"risk": "read",
"availability": "available",
"primary": "+list-executed",
"public": false,
"reviewed": true
},
"+list-forms": {
"disposition": "semantic_adapter",
"semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失、畸形、停滞或倒退游标,下游修复前不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+list-pending": {
"disposition": "semantic_adapter",
"semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+pending": {
"disposition": "alias_internal",
"semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均保持非公开。",
"risk": "read",
"availability": "available",
"primary": "+list-pending",
"public": false,
"reviewed": true
},
"+list-cc": {
"disposition": "semantic_adapter",
"semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture,因此不进入 Agent 公开发现。",
"risk": "read",
"availability": "available",
"public": false,
"compatibility_visible": true,
"reviewed": true
},
"+approve-by": {
"disposition": "primary_smart",
"semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,因此不进入 Agent 公开发现。",
"risk": "high-risk-write",
"availability": "available",
"public": false,
"reviewed": true
}
}
}
@@ -0,0 +1,39 @@
{
"version": 1,
"service": "report",
"default_availability": "unavailable",
"shortcuts": {
"+inbox-list": {
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+outbox-list": {
"disposition": "semantic_adapter",
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+template-search": {
"disposition": "semantic_adapter",
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+report-latest": {
"disposition": "primary_smart",
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
}
}
}
-277
View File
@@ -1,277 +0,0 @@
// 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 (
"fmt"
"strconv"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// Approve: agree to ONE of MY pending OA approvals by matching a keyword in its
// title / order number, in a single command (钉钉原生编排能力).
//
// Steps (all tool names + params copied verbatim from helpers/oa.go):
//
// 1. list my pending approvals via list_pending_approvals (starTime / endTime
// as float64 millis over a recent window, plus query=keyword for
// server-side narrowing — mirroring `dws oa approval list-pending`);
//
// 2. scan the returned instances and keep the ones whose title / order number
// contains --keyword: none → "没找到待审批单据", many → list candidates
// (标题 + processInstanceId) so the caller can be more specific, exactly one
// → take its processInstanceId;
//
// 3. resolve the pending taskId for that instance via list_pending_tasks
// (processInstanceId), then agree via approve_processInstance
// (processInstanceId + taskId as float64, optional remark) —
// mirroring `dws oa approval tasks` + `dws oa approval approve`.
//
// dws oa +approve-by --keyword 报销
// dws oa +approve-by --keyword 出差单 --comment "同意"
var Approve = shortcut.Shortcut{
Service: "oa",
Command: "+approve-by",
Product: "oa",
Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
Intent: "当你只记得某张待你审批单据的标题或单号关键词、想直接把它审批通过,却不想先翻待办列表、" +
"复制审批实例 ID 再查任务 ID 时使用;内部先拉取你近三个月待处理的审批单,按标题/单号包含关键词匹配:" +
"没匹配到会提示「没找到待审批单据」,匹配到多条会列出候选(标题+实例ID)让你写得更精确," +
"唯一命中时先取出它的待审批任务 ID,再以「同意」提交。这会真实提交审批决策且不可撤回,请确认关键词足够精确。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true},
{Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)", Required: false},
},
Tips: []string{
`dws oa +approve-by --keyword 报销`,
`dws oa +approve-by --keyword 出差单 --comment "同意"`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
keyword := strings.TrimSpace(rt.Str("keyword"))
if keyword == "" {
return apperrors.NewValidation("请用 --keyword 提供待审批单据的单号或标题关键词")
}
// Step 1 — list my pending approvals. starTime/endTime are float64
// milliseconds and query narrows server-side, mirroring
// helpers.list-pending (list_pending_approvals). Window: last ~90 days.
now := time.Now()
listArgs := map[string]any{
"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()),
"endTime": float64(now.UnixMilli()),
"query": keyword,
}
data, err := rt.CallMCPData("oa", "list_pending_approvals", listArgs)
if err != nil {
return err
}
// Step 2 — match by title / order number substring.
matches := shortcutApproveMatch(data, keyword)
switch {
case len(matches) == 0:
return apperrors.NewValidation(fmt.Sprintf(
"没找到待审批单据:待我处理的审批里没有标题/单号包含 %q 的单据。", keyword))
case len(matches) > 1:
return apperrors.NewValidation(fmt.Sprintf(
"%q 匹配到 %d 条待审批单据,请用更精确的关键词,或用 `dws oa approval approve --instance-id --task-id` 指定:%s",
keyword, len(matches), strings.Join(shortcutApproveLabels(matches), ";")))
}
instanceID := matches[0].instanceID
// Step 3a — resolve the pending taskId for this instance via
// list_pending_tasks (processInstanceId), mirroring `dws oa approval tasks`.
tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{
"processInstanceId": instanceID,
})
if err != nil {
return err
}
taskID := shortcutApproveTaskID(tasksData)
if taskID == "" {
return apperrors.NewValidation(fmt.Sprintf(
"没能拿到 %q 的待审批任务 ID,请用 `dws oa approval tasks --instance-id %s` 查看后手动 approve。",
matches[0].title, instanceID))
}
// Step 3b — agree. taskId is passed as float64 and remark is optional,
// mirroring helpers.approve (approve_processInstance).
taskIDNum, err := strconv.ParseFloat(taskID, 64)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("任务 ID %q 不是合法数字,无法审批。", taskID))
}
approveArgs := map[string]any{
"processInstanceId": instanceID,
"taskId": taskIDNum,
}
if c := strings.TrimSpace(rt.Str("comment")); c != "" {
approveArgs["remark"] = c
}
return rt.CallMCP("approve_processInstance", approveArgs)
},
}
// shortcutApproveInstance is the minimal identity we need from a pending-approval
// entry: its processInstanceId (fed to approve/tasks) and a human title/order
// number for matching + disambiguation.
type shortcutApproveInstance struct {
instanceID string
title string
}
// shortcutApproveMatch walks a list_pending_approvals response and returns the
// instances whose title / order number contains keyword (case-insensitive). The
// gateway wraps the list under one of several common container keys, so we probe
// them defensively (mirroring latestMinutesItems / shortcutTodoCards).
func shortcutApproveMatch(data map[string]any, keyword string) []shortcutApproveInstance {
items := shortcutApproveItems(data)
kw := strings.ToLower(keyword)
var out []shortcutApproveInstance
seen := map[string]bool{}
for _, m := range items {
id := shortcutApproveInstanceID(m)
if id == "" || seen[id] {
continue
}
title := shortcutApproveTitle(m)
// Match on either the visible title/order number or the instance id.
if !strings.Contains(strings.ToLower(title), kw) &&
!strings.Contains(strings.ToLower(id), kw) {
continue
}
seen[id] = true
if title == "" {
title = id
}
out = append(out, shortcutApproveInstance{instanceID: id, title: title})
}
return out
}
// shortcutApproveItems pulls the list of pending-approval entries out of the
// response, probing common container shapes before scanning for a bare list.
func shortcutApproveItems(data map[string]any) []map[string]any {
for _, key := range []string{"result", "list", "instances", "items", "data", "records", "processList"} {
if arr, ok := data[key].([]any); ok {
return shortcutApproveToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "instances", "items", "records", "result", "processList"} {
if arr, ok := inner[k2].([]any); ok {
return shortcutApproveToMaps(arr)
}
}
}
}
return nil
}
func shortcutApproveToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// shortcutApproveInstanceID reads an entry's processInstanceId (the value the
// helper feeds to --instance-id), tolerating a few common field names and both
// string / numeric JSON encodings.
func shortcutApproveInstanceID(m map[string]any) string {
for _, key := range []string{"processInstanceId", "processInstanceID", "instanceId", "businessId"} {
if s := shortcutApproveStr(m[key]); s != "" {
return s
}
}
return ""
}
// shortcutApproveTitle reads a human-facing title / order number for matching
// and disambiguation, probing common field names.
func shortcutApproveTitle(m map[string]any) string {
for _, key := range []string{"title", "processTitle", "name", "subject", "businessId", "processCode"} {
if s := shortcutApproveStr(m[key]); s != "" {
return s
}
}
return ""
}
// shortcutApproveTaskID walks a list_pending_tasks response and returns the
// first pending taskId (the value the helper feeds to --task-id).
func shortcutApproveTaskID(data map[string]any) string {
items := shortcutApproveItems(data)
// list_pending_tasks may also return a bare {"result": [...]}/task list; the
// probe above covers those. Some gateways return a single task object.
if len(items) == 0 {
if id := shortcutApproveTaskIDField(data); id != "" {
return id
}
if r, ok := data["result"].(map[string]any); ok {
if id := shortcutApproveTaskIDField(r); id != "" {
return id
}
}
return ""
}
for _, m := range items {
if id := shortcutApproveTaskIDField(m); id != "" {
return id
}
}
return ""
}
func shortcutApproveTaskIDField(m map[string]any) string {
for _, key := range []string{"taskId", "taskID", "activityId"} {
if s := shortcutApproveStr(m[key]); s != "" {
return s
}
}
return ""
}
// shortcutApproveStr coerces a JSON value into a string, tolerating string and
// numeric encodings (taskId / instance id can arrive as either).
func shortcutApproveStr(v any) string {
switch t := v.(type) {
case string:
return t
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case int64:
return strconv.FormatInt(t, 10)
}
return ""
}
func shortcutApproveLabels(items []shortcutApproveInstance) []string {
out := make([]string, 0, len(items))
for _, c := range items {
out = append(out, fmt.Sprintf("%s(instanceId=%s)", c.title, c.instanceID))
}
return out
}
func init() {
shortcut.Register(Approve)
}
@@ -23,6 +23,7 @@ import (
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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
const calendarSmartMaxPages = 20
@@ -215,6 +216,7 @@ func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string
if !hasMore && next != "" {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空")
}
list, _ = calendarcompat.NormalizeTerminalEmptyEvents(list, result)
events := make([]map[string]any, 0, len(list))
for index, item := range list {
event, ok := item.(map[string]any)
@@ -148,6 +148,18 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
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)
}
items, hasMore, next, err = calendarSmartEventPage(map[string]any{
"success": true,
"result": map[string]any{
"events": []any{map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
}},
"hasMore": false,
},
})
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
t.Fatalf("service empty sentinel items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
invalidPages := []map[string]any{
{},
@@ -157,6 +169,7 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
{"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{map[string]any{"summary": nil}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}},
}
for index, page := range invalidPages {
-109
View File
@@ -1,109 +0,0 @@
// 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 (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// DoneApprovals: READ-ONLY list of the approval tasks I have already processed
// (my approval history), projected to clean fields. Unlike `oa +approve-by`
// (which agrees to an approval) or `oa +pending` (which lists what still waits
// for me), this shortcut only looks back at what I've already handled — it never
// approves / rejects / mutates anything.
//
// Steps (tool name + param keys copied verbatim from helpers/oa.go):
//
// 1. list my done approvals via get_done_tasks with pageNumber / pageSize as
// float64, mirroring `dws oa approval list-executed`; optional --limit maps
// to pageSize (float64). This tool takes NO time window (unlike
// list_pending_approvals) — only pageNumber/pageSize/query.
//
// 2. defensively unwrap the returned instance list (reusing shortcutApproveItems
// from approve.go, multiple candidate container keys + one nested level) and
// project each entry to a readable shape {title, originatorName,
// processInstanceId, createTime}, every field probed across candidate keys.
//
// 3. if nothing has been processed, report "没有已处理的审批记录" instead of an
// empty raw dump.
//
// dws oa +done-approvals
// dws oa +done-approvals --limit 10
var DoneApprovals = shortcut.Shortcut{
Service: "oa",
Command: "+done-approvals",
Product: "oa",
Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表",
Intent: "当你只想快速回看「我已经处理过(同意/拒绝)」的审批任务历史——每条的标题、发起人、审批实例 ID 和创建时间——" +
"而不想拿到一大坨原始字段时使用;内部拉取你的已办审批单,再在本地投影出可读字段。" +
"这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改任何审批;" +
"若没有任何已处理记录则提示「没有已处理的审批记录」。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false},
},
Tips: []string{
`dws oa +done-approvals`,
`dws oa +done-approvals --limit 10`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my done approvals. pageNumber/pageSize are float64. Keys
// copied verbatim from helpers.list-executed (get_done_tasks). This tool
// carries no time window; pageNumber defaults to the first page.
// pageSize is required by get_done_tasks — the 1:1 list-executed always
// sends one (a missing/zero pageSize triggers a backend business error),
// so default to 20 and let --limit override.
params := map[string]any{
"pageNumber": float64(1),
"pageSize": float64(20),
}
if rt.Changed("limit") {
if n := rt.Int("limit"); n > 0 {
params["pageSize"] = float64(n)
}
}
data, err := rt.CallMCPData("oa", "get_done_tasks", params)
if err != nil {
return err
}
// Step 2 — project entries. shortcutApproveItems (approve.go) defensively
// unwraps the list container (result/list/instances/items/data/records/
// processList, incl. one nested level).
items := shortcutApproveItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
// shortcutApproveTitle probes title/processTitle/name/subject/…
"title": shortcutApproveTitle(m),
// shortcutApproveInstanceID probes processInstanceId/instanceId/…
"processInstanceId": shortcutApproveInstanceID(m),
"originatorName": pendingApprovalsOriginator(m),
"createTime": pendingApprovalsCreateTime(m),
})
}
// Step 3 — empty result guard.
if len(results) == 0 {
return apperrors.NewValidation("没有已处理的审批记录")
}
return rt.Output(map[string]any{"count": len(results), "done": results})
},
}
func init() {
shortcut.Register(DoneApprovals)
}
-247
View File
@@ -1,247 +0,0 @@
// 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 (
"strconv"
"strings"
"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/shortcut"
)
// MyInitiated: list the OA approval instances *I* have initiated (submitted), in
// one step.
//
// Steps:
//
// 1. call get_submitted_instances on the OA server — the exact tool + parameter
// names (pageNumber / pageSize as float64, optional query) used by
// helpers.approvalSubmittedListCmd ("dws oa approval list-submitted"). page
// and limit default to the same first-page values (page 1, limit 20).
//
// 2. defensively locate the instance list inside the response (probing common
// container keys, incl. a nested result/data object) and project each item
// down to {title, businessId, status, processInstanceId} with multiple
// candidate keys per field. When no recognisable list is found we print the
// raw payload so nothing is silently lost.
//
// 3. print via rt.Output so it honours --format / --jq / --fields.
//
// Read-only: it only lists and reshapes my submitted approvals, it never
// approves, rejects, revokes or mutates anything.
//
// dws oa +my-initiated
// dws oa +my-initiated --query 报销
// dws oa +my-initiated --page 2 --limit 50
var MyInitiated = shortcut.Shortcut{
Service: "oa",
Command: "+my-initiated",
Product: "oa",
Description: "列出我发起(提交)的审批单据",
Intent: "当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;" +
"内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted)," +
"再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。" +
"可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。" +
"这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "shortcut_my_initiated",
CanonicalPath: "oa.shortcut_my_initiated",
CLIPath: "oa +my-initiated",
PrimaryCLIPath: "oa +my-initiated",
},
Description: "列出我发起(提交)的审批单据",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "列出我发起(提交)的审批单据",
UseWhen: []string{"当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted),再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws oa +my-initiated",
"dws oa +my-initiated --query 报销",
},
},
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)", Required: false},
{Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20", Required: false},
},
Tips: []string{
`dws oa +my-initiated`,
`dws oa +my-initiated --query 报销`,
`dws oa +my-initiated --page 2 --limit 50`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my submitted approval instances. pageNumber/pageSize are
// float64 and query is optional, mirroring helpers.approvalSubmittedListCmd.
page := rt.Int("page")
if page <= 0 {
page = 1
}
limit := rt.Int("limit")
if limit <= 0 {
limit = 20
}
params := map[string]any{
"pageNumber": float64(page),
"pageSize": float64(limit),
}
if q := strings.TrimSpace(rt.Str("query")); q != "" {
params["query"] = q
}
data, err := rt.CallMCPData("oa", "get_submitted_instances", params)
if err != nil {
return err
}
// Step 2 — project the instance list; fall back to the raw payload when we
// cannot locate a recognisable list.
items := myInitiatedItems(data)
if len(items) == 0 {
return rt.Output(data)
}
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"title": myInitiatedTitle(m),
"businessId": myInitiatedBusinessID(m),
"status": myInitiatedStatus(m),
"processInstanceId": myInitiatedInstanceID(m),
})
}
// Step 3 — print the projected list.
return rt.Output(map[string]any{"initiated": results})
},
}
// myInitiatedItems locates the instance list inside a get_submitted_instances
// response, probing common container keys at the top level and nested under a
// result/data object. Returns nil when no list is found.
func myInitiatedItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
keys := []string{"list", "instances", "processInstances", "items", "data", "records", "result", "rows"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return myInitiatedToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "instances", "processInstances", "items", "data", "records", "rows"} {
if arr, ok := inner[k2].([]any); ok {
return myInitiatedToMaps(arr)
}
}
}
}
return nil
}
func myInitiatedToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// myInitiatedTitle reads an instance's human-readable title, tolerating the
// common title keys the gateway may use.
func myInitiatedTitle(m map[string]any) any {
for _, key := range []string{"title", "subject", "name", "processName", "formName", "instanceTitle"} {
if v := myInitiatedString(m[key]); v != "" {
return v
}
}
return nil
}
// myInitiatedBusinessID reads an instance's business / order number, preferring
// the customer-visible business id and falling back to related identifiers.
func myInitiatedBusinessID(m map[string]any) any {
for _, key := range []string{"businessId", "bizId", "orderNo", "orderNumber", "number", "serialNumber"} {
if v := myInitiatedString(m[key]); v != "" {
return v
}
}
return nil
}
// myInitiatedStatus reads an instance's current status/result, tolerating both
// the running status and the final result keys.
func myInitiatedStatus(m map[string]any) any {
for _, key := range []string{"status", "statusText", "processStatus", "instanceStatus", "result", "approvalResult"} {
if v := myInitiatedString(m[key]); v != "" {
return v
}
}
return nil
}
// myInitiatedInstanceID reads an instance's processInstanceId, tolerating the
// common id keys.
func myInitiatedInstanceID(m map[string]any) any {
for _, key := range []string{"processInstanceId", "instanceId", "processInstanceID", "id", "bizId"} {
if v := myInitiatedString(m[key]); v != "" {
return v
}
}
return nil
}
// myInitiatedString coerces a scalar JSON value to a trimmed string, returning
// "" for nil / non-scalar / empty values.
func myInitiatedString(v any) string {
switch typed := v.(type) {
case string:
return strings.TrimSpace(typed)
case float64:
// approval ids/numbers may arrive as JSON numbers; render without a
// trailing decimal point when integral.
return strconv.FormatFloat(typed, 'f', -1, 64)
case int64:
return strconv.FormatInt(typed, 10)
case int:
return strconv.Itoa(typed)
case bool:
if typed {
return "true"
}
return "false"
default:
return ""
}
}
func init() {
shortcut.Register(MyInitiated)
}

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