Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6f90c651f |
+1
-3
@@ -25,9 +25,7 @@ category: Added
|
||||
|
||||
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
|
||||
将未归档 fragments 汇总为唯一的版本章节,并移动到
|
||||
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
|
||||
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
|
||||
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
|
||||
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
|
||||
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
|
||||
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -534,8 +534,7 @@ 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. The CrossPlatformCoverage-heavy C range is split again
|
||||
# to retain headroom on runners reclaimed near the five-minute mark.
|
||||
# its job silently.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -543,10 +542,7 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
@@ -682,8 +678,7 @@ 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. The
|
||||
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -691,10 +686,7 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
|
||||
+2
-7
@@ -140,13 +140,8 @@ 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>/`。如果 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 同时提交生成的章节与
|
||||
归档移动,供审计复核。
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
|
||||
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-20T11:51:44.156046",
|
||||
"count": 416,
|
||||
"generated_at": "2026-08-19T10:35:58.304269",
|
||||
"count": 423,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -2494,14 +2494,32 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"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",
|
||||
"service": "ding",
|
||||
"command": "+receiver-status",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"service": "ding",
|
||||
"command": "+send-personal",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3584,54 +3602,67 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"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",
|
||||
"service": "oa",
|
||||
"command": "+search-forms",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "report",
|
||||
"command": "+inbox-list",
|
||||
"risk": "read",
|
||||
"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"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "report",
|
||||
"command": "+outbox-list",
|
||||
"risk": "read",
|
||||
"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"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"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 +search-forms", "oa approval list-submitted", "oa approval list-initiated"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,7 @@ 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"},
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 415
|
||||
publicShortcutCount = 422
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 453
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 447
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 415
|
||||
publiclyDeliveredShortcutCount = 422
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
|
||||
@@ -191,12 +191,12 @@
|
||||
"from": "oa +list-processes",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"oa +search-forms",
|
||||
"oa approval list-submitted",
|
||||
"oa +list-forms",
|
||||
"oa +my-initiated",
|
||||
"oa approval list-initiated"
|
||||
],
|
||||
"reviewed": true,
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-detail",
|
||||
|
||||
@@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{
|
||||
{
|
||||
From: "oa +list-processes",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
|
||||
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
Reviewed: true,
|
||||
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.",
|
||||
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.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
|
||||
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "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"},
|
||||
}
|
||||
|
||||
@@ -6655,6 +6655,19 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
CLIPath: "minutes upload create",
|
||||
Blocked: []string{"file", "file-path", "path", "template-ids"},
|
||||
},
|
||||
{
|
||||
CLIPath: "oa +list-executed",
|
||||
Aliases: map[string]string{
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"page-size": "limit",
|
||||
"per-page": "limit",
|
||||
"size": "limit",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
},
|
||||
Blocked: []string{"count", "cursor"},
|
||||
},
|
||||
{
|
||||
CLIPath: "oa +search-forms",
|
||||
Aliases: map[string]string{
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search"],"risk":"green"},
|
||||
"pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"},
|
||||
"pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"},
|
||||
"page_number": {"denotes":"one-based page number","canonical_hint":"page","members":["page","page-no","current-page","page-num"],"excludes":["cursor","page-index","page-size","page-token"],"commands":["devdoc article search","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list"],"risk":"green"},
|
||||
"page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space list"],"risk":"green"},
|
||||
"content_text": {"denotes":"text body content","canonical_hint":"text","members":["text","content","body"],"excludes":["title","name"],"commands":["doc +checkpoint-update","doc +comment-create","doc +comment-reply","doc +comment-update","doc +create","doc +doc-append","doc block insert","doc block update","doc comment create","doc comment create-inline","doc comment reply","doc comment update","doc create","minutes +summary","minutes update summary","todo +comment","todo comment add"],"risk":"green"},
|
||||
@@ -458,6 +458,7 @@
|
||||
{"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2},
|
||||
{"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2},
|
||||
{"command": "oa +list-executed", "emitted": "take", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2},
|
||||
{"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2},
|
||||
{"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
|
||||
|
||||
@@ -17,16 +17,6 @@ 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.
|
||||
@@ -242,15 +232,11 @@ 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": remindType,
|
||||
"remindType": mustGetFlag(cmd, "type"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
@@ -276,16 +262,12 @@ 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": remindType,
|
||||
"remindType": mustGetFlag(cmd, "type"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
// 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"),
|
||||
)
|
||||
}
|
||||
+175
-154
@@ -1,205 +1,226 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
// 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 ding registers strict declarative shortcuts for DingTalk DING.
|
||||
// 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
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
var List = shortcut.Shortcut{
|
||||
Service: "ding", Command: "+list", Product: "im",
|
||||
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: "分页游标,首次不传或传 0"},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型:ALL、UNREAD、SEND、NEW_COMMENT 或 DELETED"},
|
||||
Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
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
|
||||
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"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "类型 (可选,默认 ALL)"},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"type": rt.Str("type")}
|
||||
// type 是服务端必填,空值会报错;不传或传空时兜底为 ALL(对齐 helper)。
|
||||
msgType := rt.Str("type")
|
||||
if msgType == "" {
|
||||
msgType = "ALL"
|
||||
}
|
||||
params := map[string]any{"type": msgType}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Int("cursor")
|
||||
}
|
||||
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)
|
||||
return rt.CallMCP("list_ding_messages", params)
|
||||
},
|
||||
}
|
||||
|
||||
var ReceiverStatus = shortcut.Shortcut{
|
||||
Service: "ding", Command: "+receiver-status", Product: "im",
|
||||
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>"},
|
||||
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},
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
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)))
|
||||
return rt.CallMCP("list_ding_receiver_status", map[string]any{
|
||||
"openDingId": rt.Str("ding-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var SendPersonal = compatibilityDingWrite(
|
||||
"+send-personal", "以本人身份发送 DING 给指定人",
|
||||
"仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。",
|
||||
shortcut.RiskWrite, false,
|
||||
[]shortcut.Flag{
|
||||
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{
|
||||
{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: "提醒方式:app、sms 或 call"},
|
||||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
|
||||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
|
||||
},
|
||||
[]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
|
||||
}
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"receiverOpenDingTalkIds": rt.StrSlice("users"),
|
||||
"content": rt.Str("content"),
|
||||
"remindType": remindType,
|
||||
"remindType": rt.Str("type"),
|
||||
}
|
||||
if rt.Changed("uuid") {
|
||||
params["uuid"] = rt.Str("uuid")
|
||||
}
|
||||
return rt.CallMCP("send_personal_ding", params)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var SendByMessage = unavailableDingWrite(
|
||||
"+send-by-message", "针对某条消息发起 DING 提醒",
|
||||
"需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。",
|
||||
shortcut.RiskWrite, false,
|
||||
[]shortcut.Flag{
|
||||
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{
|
||||
{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: "提醒方式", Enum: []string{"app", "sms", "call"}},
|
||||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
|
||||
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
|
||||
},
|
||||
[]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"},
|
||||
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)
|
||||
},
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 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")
|
||||
}
|
||||
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 init() {
|
||||
shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal)
|
||||
shortcut.Register(
|
||||
List,
|
||||
ReceiverStatus,
|
||||
SendPersonal,
|
||||
SendByMessage,
|
||||
RecallPersonal,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
+543
-194
@@ -1,73 +1,59 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
// 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 registers strict declarative shortcuts for DingTalk OA approval.
|
||||
// Package oa registers declarative shortcuts for the DingTalk OA approval
|
||||
// service, wrapping the raw MCP tools exposed by the dws oa helper.
|
||||
package oa
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ListPending — 查询待我处理的审批 (list_pending_approvals)
|
||||
var ListPending = shortcut.Shortcut{
|
||||
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"},
|
||||
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",
|
||||
},
|
||||
"dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20",
|
||||
),
|
||||
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 报销"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "start", Type: shortcut.FlagInt, Desc: "开始时间(epoch 毫秒)", Required: true},
|
||||
{Name: "end", Type: shortcut.FlagInt, Desc: "结束时间(epoch 毫秒)", Required: true},
|
||||
@@ -75,25 +61,12 @@ var ListPending = shortcut.Shortcut{
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"},
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
|
||||
},
|
||||
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)
|
||||
},
|
||||
Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
page, _ := parseOAStringPage(rt, "page", 1)
|
||||
params := map[string]any{"starTime": rt.Int("start"), "endTime": rt.Int("end")}
|
||||
params := map[string]any{
|
||||
"starTime": rt.Int("start"),
|
||||
"endTime": rt.Int("end"),
|
||||
}
|
||||
if rt.Changed("page") {
|
||||
params["pageNum"] = rt.Str("page")
|
||||
}
|
||||
@@ -103,154 +76,530 @@ var ListPending = shortcut.Shortcut{
|
||||
if rt.Changed("query") {
|
||||
params["query"] = rt.Str("query")
|
||||
}
|
||||
return oaInstancePage(rt, "list_pending_approvals", params, page)
|
||||
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})
|
||||
},
|
||||
}
|
||||
|
||||
// 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: "按服务端游标读取当前用户可发起的审批表单;缺少 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",
|
||||
),
|
||||
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"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 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
|
||||
},
|
||||
Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
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")})
|
||||
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, 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)
|
||||
forms := listFormsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(forms), "forms": forms})
|
||||
},
|
||||
}
|
||||
|
||||
// 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 时使用;要无条件遍历全部定义不要使用本命令。",
|
||||
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
|
||||
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",
|
||||
},
|
||||
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 {
|
||||
const operation = "oa/search_form"
|
||||
data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": strings.TrimSpace(rt.Str("query"))})
|
||||
data, err := rt.CallMCPData("oa", "search_form", map[string]any{
|
||||
"query": rt.Str("query"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
forms, err := oaProjectForms(data, operation, "result")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return outputOACompleteCollection(rt, "forms", forms)
|
||||
forms := searchFormsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(forms), "forms": forms})
|
||||
},
|
||||
}
|
||||
|
||||
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: "关键字搜索"},
|
||||
},
|
||||
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 " + command + " --page 1 --limit 20"},
|
||||
}
|
||||
declaration.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
page, err := parseOAStringPage(rt, "page", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
// 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
|
||||
}
|
||||
limit, err := parseOAStringPage(rt, "limit", 20)
|
||||
if err != nil {
|
||||
return err
|
||||
if row := oaFormProjectItem(m); len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
return validateOAPage(page, limit)
|
||||
}
|
||||
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
|
||||
return out
|
||||
}
|
||||
|
||||
var ListExecuted = oaNumberedInstanceShortcut(
|
||||
"+list-executed", "get_done_tasks", "获取当前用户已经处理过的审批单列表",
|
||||
"需要回顾当前用户已同意或拒绝过的审批实例时使用;与待办、已发起和抄送列表分开。",
|
||||
)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instances := listExecutedProject(data)
|
||||
return rt.Output(map[string]any{"count": len(instances), "instances": instances})
|
||||
},
|
||||
}
|
||||
|
||||
var ListSubmitted = oaNumberedInstanceShortcut(
|
||||
"+list-submitted", "get_submitted_instances", "获取当前用户已发起的审批单列表",
|
||||
"需要查看当前用户发起的审批实例和当前状态时使用;返回稳定 processInstanceId 与可续页证据。",
|
||||
)
|
||||
// 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
|
||||
}
|
||||
|
||||
var ListCc = oaNumberedInstanceShortcut(
|
||||
"+list-cc", "get_noticed_instances", "获取抄送当前用户的审批单列表",
|
||||
"需要查看抄送给当前用户的审批实例时使用;没有安全非空抄送 fixture 前不会进入公开发现。",
|
||||
)
|
||||
// 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",
|
||||
},
|
||||
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{"当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +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)
|
||||
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)
|
||||
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 out
|
||||
}
|
||||
|
||||
// RedirectTask — 转交审批任务给其他人 (redirect_task)
|
||||
// Comment — 对审批实例添加评论 (dingflow_comments)
|
||||
// CcNotice — 对审批实例进行抄送 (oa_cc_noticer)
|
||||
// AppendTask — 对审批任务进行加签 (append_task)
|
||||
// RevertActivities — 获取审批任务可回退的节点信息 (get_inst_revert_activities)
|
||||
// RevertTask — 退回审批任务到指定节点 (revert_task)
|
||||
func init() {
|
||||
shortcut.Register(ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, ListCc)
|
||||
shortcut.Register(
|
||||
ListPending,
|
||||
ListForms,
|
||||
SearchForms,
|
||||
ListExecuted,
|
||||
ListSubmitted,
|
||||
ListCc,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,728 +0,0 @@
|
||||
// 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])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
// 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,7 +265,10 @@ 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": {},
|
||||
@@ -374,11 +377,15 @@ 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": {},
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
// 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)))
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
+236
-231
@@ -1,126 +1,103 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
// 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 registers strict declarative shortcuts for DingTalk reports.
|
||||
// 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
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
const (
|
||||
reportMaximumInboxDays = 180
|
||||
reportMaximumOutboxDays = 20
|
||||
)
|
||||
|
||||
func reportParseISOMillis(name, value string) (int64, error) {
|
||||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
|
||||
// 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)
|
||||
if err != nil {
|
||||
return 0, apperrors.NewValidation(
|
||||
fmt.Sprintf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00)", name),
|
||||
apperrors.WithReason("invalid_time_format"),
|
||||
)
|
||||
return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err)
|
||||
}
|
||||
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
|
||||
return t.UnixMilli(), 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: "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 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"},
|
||||
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",
|
||||
},
|
||||
"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20",
|
||||
),
|
||||
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"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{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 列表"},
|
||||
{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 列表,逗号分隔,过滤指定发送人"},
|
||||
},
|
||||
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 之间"},
|
||||
},
|
||||
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
|
||||
}
|
||||
_, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays)
|
||||
return err
|
||||
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`,
|
||||
},
|
||||
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)
|
||||
startMs, err := reportParseISOMillis("start", rt.Str("start"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endMs, err := reportParseISOMillis("end", rt.Str("end"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"startTime": start, "endTime": end,
|
||||
"cursor": rt.Int("cursor"), "size": rt.Int("size"),
|
||||
"startTime": startMs,
|
||||
"endTime": endMs,
|
||||
"cursor": rt.Int("cursor"),
|
||||
"size": rt.Int("size"),
|
||||
}
|
||||
if rt.Changed("sender-user-ids") {
|
||||
params["senderUserIds"] = rt.StrSlice("sender-user-ids")
|
||||
@@ -129,164 +106,192 @@ var InboxList = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
reports := reportEntryListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(reports), "reports": reports})
|
||||
},
|
||||
}
|
||||
|
||||
var OutboxList = shortcut.Shortcut{
|
||||
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"},
|
||||
},
|
||||
"dws report +outbox-list --cursor 0 --size 20",
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{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: "日志模板名称"},
|
||||
},
|
||||
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 天"},
|
||||
},
|
||||
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
|
||||
// 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
|
||||
}
|
||||
if rt.Changed("start") && rt.Changed("end") {
|
||||
if _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays); err != nil {
|
||||
return err
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
if rt.Changed("modified-start") != rt.Changed("modified-end") {
|
||||
return apperrors.NewValidation("--modified-start 与 --modified-end 必须同时提供", apperrors.WithReason("incomplete_modified_range"))
|
||||
}
|
||||
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
|
||||
}
|
||||
if rt.Changed("modified-start") {
|
||||
_, _, err := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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",
|
||||
},
|
||||
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 \"日报\"",
|
||||
},
|
||||
},
|
||||
},
|
||||
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: "日志模板名称 (可选,不传查全部)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws report +outbox-list --cursor 0 --size 20`,
|
||||
`dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`,
|
||||
},
|
||||
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)
|
||||
params := map[string]any{
|
||||
"cursor": rt.Int("cursor"),
|
||||
"size": rt.Int("size"),
|
||||
}
|
||||
if rt.Changed("start") {
|
||||
startValue = rt.Str("start")
|
||||
ms, err := reportParseISOMillis("start", rt.Str("start"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["startTime"] = ms
|
||||
}
|
||||
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,
|
||||
ms, err := reportParseISOMillis("end", rt.Str("end"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["endTime"] = ms
|
||||
}
|
||||
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
|
||||
ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["modifiedStartTime"], params["modifiedEndTime"] = modifiedStart, modifiedEnd
|
||||
params["modifiedStartTime"] = ms
|
||||
}
|
||||
if name := strings.TrimSpace(rt.Str("template-name")); name != "" {
|
||||
params["report_template_name"] = name
|
||||
if rt.Changed("modified-end") {
|
||||
ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["modifiedEndTime"] = ms
|
||||
}
|
||||
if rt.Changed("template-name") {
|
||||
params["report_template_name"] = rt.Str("template-name")
|
||||
}
|
||||
data, err := rt.CallMCPData("report", "get_send_report_list", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
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)))
|
||||
reports := reportEntryListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(reports), "reports": reports})
|
||||
},
|
||||
}
|
||||
|
||||
// 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.
|
||||
func init() {
|
||||
shortcut.Register(InboxList, OutboxList, TemplateSearch)
|
||||
shortcut.Register(
|
||||
InboxList,
|
||||
OutboxList,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -40,15 +40,6 @@ 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"`
|
||||
@@ -78,9 +69,6 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
todoSemanticCatalogJSON,
|
||||
attendanceSemanticCatalogJSON,
|
||||
mailSemanticCatalogJSON,
|
||||
oaSemanticCatalogJSON,
|
||||
dingSemanticCatalogJSON,
|
||||
reportSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
@@ -142,12 +130,11 @@ 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 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))
|
||||
// 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))
|
||||
}
|
||||
key := publicCatalogKey(source.Service, command)
|
||||
if _, exists := out[key]; exists {
|
||||
|
||||
@@ -136,6 +136,21 @@ 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 })
|
||||
@@ -195,28 +210,6 @@ 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) {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// 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 (
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// PendingApprovals: READ-ONLY list of the approvals waiting for ME, projected to
|
||||
// clean fields. Unlike `oa +approve-by` (which actually agrees to an approval),
|
||||
// this shortcut only looks — it never approves / rejects / mutates anything.
|
||||
//
|
||||
// Steps (tool name + param keys copied verbatim from helpers/oa.go):
|
||||
//
|
||||
// 1. list my pending approvals via list_pending_approvals with starTime /
|
||||
// endTime as float64 milliseconds over a recent (~90 day) window, mirroring
|
||||
// `dws oa approval list-pending`; optional --limit maps to pageSize (float64).
|
||||
//
|
||||
// 2. defensively unwrap the returned instance list (multiple candidate
|
||||
// container keys, one nested level) and project each entry to a readable
|
||||
// shape {title, originatorName, processInstanceId, createTime} — every field
|
||||
// probed across several candidate keys.
|
||||
//
|
||||
// 3. if nothing is pending, report "当前没有待我审批的任务" instead of an empty
|
||||
// raw dump.
|
||||
//
|
||||
// dws oa +pending
|
||||
// dws oa +pending --limit 10
|
||||
var PendingApprovals = shortcut.Shortcut{
|
||||
Service: "oa",
|
||||
Command: "+pending",
|
||||
Product: "oa",
|
||||
Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)",
|
||||
Intent: "当你只想快速看一眼「待我审批」的审批任务清单——每条的标题、发起人、审批实例 ID 和创建时间——" +
|
||||
"而不想拿到一大坨原始字段时使用;内部拉取你近三个月待处理的审批单,再在本地投影出可读字段。" +
|
||||
"这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改审批(要一键通过请改用 `dws oa +approve-by`);" +
|
||||
"若当前没有待你审批的任务则提示「当前没有待我审批的任务」。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws oa +pending`,
|
||||
`dws oa +pending --limit 10`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — list my pending approvals. starTime/endTime are float64
|
||||
// milliseconds; pageSize is optional. Keys copied verbatim from
|
||||
// helpers.list-pending (list_pending_approvals). Window: last ~90 days.
|
||||
now := time.Now()
|
||||
params := map[string]any{
|
||||
"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()),
|
||||
"endTime": float64(now.UnixMilli()),
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
if n := rt.Int("limit"); n > 0 {
|
||||
params["pageSize"] = float64(n)
|
||||
}
|
||||
}
|
||||
data, err := rt.CallMCPData("oa", "list_pending_approvals", 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), "pending": results})
|
||||
},
|
||||
}
|
||||
|
||||
// pendingApprovalsOriginator reads the initiator's display name, tolerating the
|
||||
// common field names the gateway may use.
|
||||
func pendingApprovalsOriginator(m map[string]any) string {
|
||||
for _, key := range []string{"originatorName", "originatorUserName", "creatorName", "creator", "applicantName", "userName"} {
|
||||
if s := shortcutApproveStr(m[key]); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pendingApprovalsCreateTime reads a pending approval's create time, returning
|
||||
// the raw value (usually epoch millis) under whichever candidate key is present.
|
||||
func pendingApprovalsCreateTime(m map[string]any) any {
|
||||
for _, key := range []string{"createTime", "gmtCreate", "createTimeStr", "startTime", "createdAt"} {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(PendingApprovals)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// ReportLatest: fetch the detail of MY most recently submitted report (日志/汇报)
|
||||
// in one step (DingTalk-native).
|
||||
//
|
||||
// Steps:
|
||||
//
|
||||
// 1. list the reports I sent via get_send_report_list. cursor/size/startTime/
|
||||
// endTime mirror helpers.runReportSent — the server caps a single query span
|
||||
// at 20 days, so we default to the last-20-days window just like the helper.
|
||||
// An optional --keyword is passed through as report_template_name (the same
|
||||
// filter the helper exposes as --template-name).
|
||||
//
|
||||
// 2. locate the newest entry: the item with the largest create time
|
||||
// (createTime/gmtCreate/sendTime), falling back to the first item that
|
||||
// carries a reportId (lists come back newest-first).
|
||||
//
|
||||
// 3. print that report's body via get_report_entry_details (report_id param
|
||||
// mirrors helpers.runReportDetail). If nothing carries a reportId we fall
|
||||
// back to printing the picked list row via rt.Output.
|
||||
//
|
||||
// If I have not sent any report in the window it reports "暂无日志" instead of
|
||||
// failing obscurely.
|
||||
//
|
||||
// dws report +report-latest
|
||||
// dws report +report-latest --keyword 日报
|
||||
var ReportLatest = shortcut.Shortcut{
|
||||
Service: "report",
|
||||
Command: "+report-latest",
|
||||
Product: "report",
|
||||
Description: "取我最新提交的一篇日志/汇报详情(钉钉原生)",
|
||||
Intent: "当你只想快速看回自己最近发出的一篇日志/汇报,却不想先翻发件箱列表、复制 reportId 再查详情时使用;" +
|
||||
"内部先列出你近期发出的日志(默认最近 20 天,服务端单次查询跨度上限 20 天;可用 --keyword 按日志模板名过滤)," +
|
||||
"自动挑出创建时间最新的一条,再拉取它的正文详情(字段明细、发送人、时间、钉钉跳转链接等)。" +
|
||||
"这是只读操作,不会创建或修改任何日志;若这段时间内你没有发出过日志则提示「暂无日志」。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名过滤(可选,对应 report outbox list 的 --template-name)", Required: false},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws report +report-latest`,
|
||||
`dws report +report-latest --keyword 日报`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — list the reports I sent. Mirror helpers.runReportSent: the
|
||||
// server caps a single get_send_report_list query at a 20-day span, so
|
||||
// default startTime/endTime to the last-20-days window. cursor/size are
|
||||
// floats and report_template_name is the optional keyword filter.
|
||||
now := time.Now()
|
||||
startMs := now.AddDate(0, 0, -20).Truncate(24 * time.Hour).UnixMilli()
|
||||
endMs := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
|
||||
|
||||
listArgs := map[string]any{
|
||||
"cursor": float64(0),
|
||||
"size": float64(20),
|
||||
"startTime": float64(startMs),
|
||||
"endTime": float64(endMs),
|
||||
}
|
||||
if kw := strings.TrimSpace(rt.Str("keyword")); kw != "" {
|
||||
listArgs["report_template_name"] = kw
|
||||
}
|
||||
|
||||
data, err := rt.CallMCPData("report", "get_send_report_list", listArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — pick the newest sent report.
|
||||
row, reportID := shortcutReportLatestPick(data)
|
||||
if row == nil {
|
||||
return apperrors.NewValidation("暂无日志")
|
||||
}
|
||||
|
||||
// Step 3 — print its detail (report_id mirrors helpers.runReportDetail).
|
||||
// If we somehow could not read a reportId, fall back to the list row.
|
||||
if reportID == "" {
|
||||
return rt.Output(row)
|
||||
}
|
||||
return rt.CallMCP("get_report_entry_details", map[string]any{
|
||||
"report_id": reportID,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// shortcutReportLatestPick walks a get_send_report_list response, finds the
|
||||
// report entries, and returns the newest one (largest create time, falling back
|
||||
// to the first entry that carries a reportId) together with its reportId.
|
||||
// Returns (nil, "") when there are no entries.
|
||||
func shortcutReportLatestPick(data map[string]any) (map[string]any, string) {
|
||||
items := shortcutReportLatestItems(data)
|
||||
if len(items) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
var firstItem map[string]any
|
||||
firstID := ""
|
||||
var bestItem map[string]any
|
||||
bestID := ""
|
||||
var bestTime int64
|
||||
haveTime := false
|
||||
|
||||
for _, m := range items {
|
||||
id := shortcutReportLatestID(m)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if firstItem == nil {
|
||||
firstItem = m
|
||||
firstID = id
|
||||
}
|
||||
if t, ok := shortcutReportLatestCreateMillis(m); ok {
|
||||
if !haveTime || t > bestTime {
|
||||
haveTime = true
|
||||
bestTime = t
|
||||
bestItem = m
|
||||
bestID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if haveTime && bestItem != nil {
|
||||
return bestItem, bestID
|
||||
}
|
||||
if firstItem != nil {
|
||||
return firstItem, firstID
|
||||
}
|
||||
// No entry carried a reportId; still surface the first raw item so the
|
||||
// caller can print something rather than mis-report "暂无日志".
|
||||
return items[0], ""
|
||||
}
|
||||
|
||||
// shortcutReportLatestItems pulls the report entries out of a
|
||||
// get_send_report_list response, probing the same container keys the helper's
|
||||
// findReportListItems uses (list/items/data/reports/records/report_list/
|
||||
// reportList nested under the top level or under result).
|
||||
func shortcutReportLatestItems(data map[string]any) []map[string]any {
|
||||
keys := []string{"list", "items", "data", "reports", "records", "report_list", "reportList", "result"}
|
||||
for _, key := range keys {
|
||||
if arr, ok := data[key].([]any); ok {
|
||||
return shortcutReportLatestToMaps(arr)
|
||||
}
|
||||
if inner, ok := data[key].(map[string]any); ok {
|
||||
for _, k2 := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
|
||||
if arr, ok := inner[k2].([]any); ok {
|
||||
return shortcutReportLatestToMaps(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shortcutReportLatestToMaps(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
|
||||
}
|
||||
|
||||
// shortcutReportLatestID reads an entry's reportId, mirroring the key aliases in
|
||||
// helpers.reportIDFromMap.
|
||||
func shortcutReportLatestID(m map[string]any) string {
|
||||
for _, key := range []string{"reportId", "reportID", "report_id", "report_Id", "report-id"} {
|
||||
if id, ok := m[key].(string); ok {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// shortcutReportLatestCreateMillis reads an entry's create time in millis,
|
||||
// mirroring the create-time keys helpers.reportReadableCreateTimeMillis probes.
|
||||
func shortcutReportLatestCreateMillis(m map[string]any) (int64, bool) {
|
||||
for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime"} {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
if v > 0 {
|
||||
return int64(v), true
|
||||
}
|
||||
case string:
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(ReportLatest)
|
||||
}
|
||||
@@ -197,8 +197,7 @@ type Shortcut struct {
|
||||
Hidden bool
|
||||
// CompatibilityVisible preserves a historically visible CLI command while
|
||||
// keeping it out of the Agent/public Shortcut catalog. Such a command is
|
||||
// shown only by `dws shortcut list --all`; Availability independently says
|
||||
// whether the historical execution path remains callable.
|
||||
// shown only by `dws shortcut list --all` and must remain unavailable.
|
||||
CompatibilityVisible bool
|
||||
// Disposition is the reviewed semantic relation to the Runtime Schema leaf
|
||||
// surface. It determines default Agent discovery independently from live
|
||||
|
||||
@@ -14,7 +14,7 @@ usage() {
|
||||
# Single source of truth for the partition set. CI runs one job per partition and
|
||||
# pins its shard names to this list, so a name that appears here without a
|
||||
# dispatch entry below fails closed rather than silently skipping tests.
|
||||
APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz'
|
||||
APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz'
|
||||
|
||||
mode="${1:-}"
|
||||
partition=""
|
||||
@@ -66,10 +66,7 @@ fi
|
||||
|
||||
schema_count=0
|
||||
ab_count=0
|
||||
cal_count=0
|
||||
cmr_count=0
|
||||
csz_count=0
|
||||
cother_count=0
|
||||
c_count=0
|
||||
dr_count=0
|
||||
sz_count=0
|
||||
unmatched_count=0
|
||||
@@ -78,10 +75,7 @@ while IFS= read -r test_name; do
|
||||
case "$test_name" in
|
||||
Test*Schema*) schema_count=$((schema_count + 1)) ;;
|
||||
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;;
|
||||
TestC*) cother_count=$((cother_count + 1)) ;;
|
||||
TestC*) c_count=$((c_count + 1)) ;;
|
||||
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
|
||||
Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;;
|
||||
*)
|
||||
@@ -102,10 +96,7 @@ classified=''
|
||||
for spec in \
|
||||
"schema:$schema_count" \
|
||||
"a-b:$ab_count" \
|
||||
"c-a-l:$cal_count" \
|
||||
"c-m-r:$cmr_count" \
|
||||
"c-s-z:$csz_count" \
|
||||
"c-other:$cother_count" \
|
||||
"c:$c_count" \
|
||||
"d-r:$dr_count" \
|
||||
"s-z-example-fuzz:$sz_count"
|
||||
do
|
||||
@@ -142,14 +133,14 @@ for name in $classified; do
|
||||
done
|
||||
|
||||
total_count="$(wc -l < "$tests" | tr -d ' ')"
|
||||
assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count))
|
||||
assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count))
|
||||
if [ "$assigned_count" -ne "$total_count" ]; then
|
||||
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
|
||||
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
|
||||
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \
|
||||
"$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count"
|
||||
|
||||
if [ "$mode" = "verify" ]; then
|
||||
exit 0
|
||||
@@ -207,10 +198,7 @@ run_named_partition() {
|
||||
case "$1" in
|
||||
schema) run_partition schema no-race "$schema_pattern" ;;
|
||||
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
|
||||
c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;;
|
||||
c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;;
|
||||
c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;;
|
||||
c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;;
|
||||
c) run_partition c race '^TestC' "$schema_pattern" ;;
|
||||
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
|
||||
s-z-example-fuzz)
|
||||
run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern"
|
||||
|
||||
@@ -29,9 +29,6 @@ SEMANTIC_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_report.json",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -68,28 +68,7 @@ if [ "$archive_changed" = true ]; then
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' >"$tmp_root/actual-section"
|
||||
case "$release_version" in
|
||||
*-beta.*)
|
||||
cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
|
||||
;;
|
||||
*)
|
||||
stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")"
|
||||
[ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
|
||||
printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2
|
||||
exit 1
|
||||
}
|
||||
. "$ROOT/scripts/release/release-lib.sh"
|
||||
[ "$(release_core_tag "$stable_beta")" = "v$release_version" ] || {
|
||||
printf 'error: stable post-beta fragment boundary %s does not match release v%s\n' "$stable_beta" "$release_version" >&2
|
||||
exit 1
|
||||
}
|
||||
awk '
|
||||
found { print }
|
||||
/^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 }
|
||||
' "$tmp_root/actual-section" >"$tmp_root/actual-notes"
|
||||
;;
|
||||
esac
|
||||
' >"$tmp_root/actual-notes"
|
||||
normalize_notes() {
|
||||
awk '
|
||||
/^[[:space:]]*$/ && !started { next }
|
||||
|
||||
@@ -71,29 +71,13 @@
|
||||
"command": "dws chat message send-by-bot",
|
||||
"legacy": {
|
||||
"name": "file-path",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local"
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"hidden": true,
|
||||
"scope": "local",
|
||||
"alias_of": "file"
|
||||
}
|
||||
"before": {"present": true, "type": "string", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "file",
|
||||
"before": {
|
||||
"present": false
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local"
|
||||
}
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
|
||||
@@ -102,29 +86,13 @@
|
||||
"command": "dws chat message send-by-bot",
|
||||
"legacy": {
|
||||
"name": "text",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local"
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"hidden": true,
|
||||
"scope": "local",
|
||||
"alias_of": "content"
|
||||
}
|
||||
"before": {"present": true, "type": "string", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "content",
|
||||
"before": {
|
||||
"present": false
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local"
|
||||
}
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
|
||||
|
||||
@@ -64,49 +64,17 @@ release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}"
|
||||
section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")"
|
||||
output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")"
|
||||
fragments="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragments.XXXXXX")"
|
||||
fragment_entries="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragment-entries.XXXXXX")"
|
||||
cleanup() { rm -f "$section" "$output" "$fragments" "$fragment_entries"; }
|
||||
cleanup() { rm -f "$section" "$output" "$fragments"; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
render_fragments=false
|
||||
if [ "$CHANNEL" = "prerelease" ]; then
|
||||
render_fragments=true
|
||||
else
|
||||
if [ -e "$CHANGES_DIR" ] && [ ! -d "$CHANGES_DIR" ]; then
|
||||
printf 'release fragments path is not a directory: %s\n' "$CHANGES_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -d "$CHANGES_DIR" ]; then
|
||||
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -print | sort >"$fragment_entries"
|
||||
while IFS= read -r entry; do
|
||||
base="${entry##*/}"
|
||||
if [ "$base" = 'README.md' ] && [ -f "$entry" ] && [ ! -L "$entry" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ "$base" = 'released' ] && [ -d "$entry" ] && [ ! -L "$entry" ]; then
|
||||
continue
|
||||
fi
|
||||
render_fragments=true
|
||||
break
|
||||
done <"$fragment_entries"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$render_fragments" = true ]; then
|
||||
"$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments"
|
||||
fi
|
||||
|
||||
{
|
||||
printf '## [%s] - %s\n\n' "$semver" "$release_date"
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA"
|
||||
printf '### Changed\n\n'
|
||||
printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA"
|
||||
if [ -s "$fragments" ]; then
|
||||
printf '\n### Changes since `%s`\n\n' "$FROM_BETA"
|
||||
cat "$fragments"
|
||||
fi
|
||||
else
|
||||
"$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments"
|
||||
cat "$fragments"
|
||||
fi
|
||||
} > "$section"
|
||||
@@ -138,18 +106,14 @@ fi
|
||||
[ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; }
|
||||
cp "$output" "$CHANGELOG"
|
||||
|
||||
if [ -s "$fragments" ]; then
|
||||
if [ "$CHANNEL" = "prerelease" ]; then
|
||||
archive_dir="$CHANGES_DIR/released/$semver"
|
||||
mkdir -p "$archive_dir"
|
||||
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -exec mv {} "$archive_dir"/ \;
|
||||
fi
|
||||
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
if [ -s "$fragments" ]; then
|
||||
printf 'Prepared CHANGELOG template and archived post-beta release fragments for %s. Replace TODO, review, commit, and merge the release-seal PR before release.\n' "$VERSION"
|
||||
else
|
||||
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
|
||||
fi
|
||||
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
|
||||
else
|
||||
printf 'Prepared CHANGELOG and archived release fragments for %s. Review, commit, and merge the release-seal PR before release.\n' "$VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ cli_version: ">=1.0.15"
|
||||
| `chat` | 98 | `dingtalk-chat` |
|
||||
| `contact` | 14 | `dingtalk-contact` |
|
||||
| `devapp` | 19 | `dingtalk-misc` |
|
||||
| `ding` | 1 | `dingtalk-misc` |
|
||||
| `ding` | 4 | `dingtalk-misc` |
|
||||
| `doc` | 45 | `dingtalk-doc` |
|
||||
| `drive` | 28 | `dingtalk-drive` |
|
||||
| `mail` | 8 | `dingtalk-mail` |
|
||||
| `minutes` | 27 | `dingtalk-minutes` |
|
||||
| `oa` | 1 | `dingtalk-misc` |
|
||||
| `report` | 4 | `dingtalk-misc` |
|
||||
| `oa` | 7 | `dingtalk-misc` |
|
||||
| `report` | 2 | `dingtalk-misc` |
|
||||
| `sheet` | 2 | `dingtalk-misc` |
|
||||
| `todo` | 21 | `dingtalk-todo` |
|
||||
| `wiki` | 20 | `dingtalk-wiki` |
|
||||
|
||||
@@ -173,7 +173,10 @@ dws ding message recall-personal --id <OPEN_DING_ID> --format json
|
||||
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws ding +list` | read | 查询 DING 消息列表 |
|
||||
| `dws ding +recall-personal` | high-risk-write | 撤回本人发起的 DING |
|
||||
| `dws ding +receiver-status` | read | 查询 DING 消息接收人已读状态 |
|
||||
| `dws ding +send-personal` | write | 以本人身份发送 DING 给指定人 |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
|
||||
@@ -963,6 +963,12 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1
|
||||
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws oa +list-cc` | read | 获取抄送当前用户的审批单列表 |
|
||||
| `dws oa +list-executed` | read | 获取当前用户已经处理过的审批单列表 |
|
||||
| `dws oa +list-forms` | read | 获取当前用户可见的审批表单列表 |
|
||||
| `dws oa +list-pending` | read | 查询待我处理的审批(时间范围为 epoch 毫秒) |
|
||||
| `dws oa +list-submitted` | read | 获取当前用户已发起的审批单列表 |
|
||||
| `dws oa +my-initiated` | read | 列出我发起(提交)的审批单据 |
|
||||
| `dws oa +search-forms` | read | 按关键字模糊搜索当前用户可见的审批表单 |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
|
||||
@@ -449,10 +449,8 @@ dws report outbox list --cursor 0 --size 20 --format json
|
||||
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws report +inbox-list` | read | 列出我收到的日志 |
|
||||
| `dws report +outbox-list` | read | 列出我发出的日志 |
|
||||
| `dws report +report-latest` | read | 读取我最近提交的一篇日志详情 |
|
||||
| `dws report +template-search` | read | 按名称搜索可用日志模板 |
|
||||
| `dws report +inbox-list` | read | 列出我收到的日报(按时间范围分页) |
|
||||
| `dws report +outbox-list` | read | 列出我发出的日报(可选时间/模版名过滤) |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
|
||||
@@ -11,11 +11,13 @@ metadata:
|
||||
|
||||
# 钉钉待办 Skill
|
||||
|
||||
## 前置条件 — 执行操作前必读
|
||||
## 执行契约
|
||||
|
||||
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
|
||||
|
||||
> 命令参考:[todo.md](references/todo.md);剧本:[02-task.md](references/02-task.md)。
|
||||
- 只用 `dws` 操作钉钉待办,命令统一加 `--format json`,并按结构化业务返回判断结果。
|
||||
- 已知路线直接执行;仅在 leaf 参数或安全语义不确定时查精确 Schema,在 flag 不确定时查精确 Help。不要先枚举整个 Catalog,也不要连续猜命令。
|
||||
- 后续 ID 必须来自本次真实返回;零匹配、多匹配或类型不明时停止并消歧。
|
||||
- 写操作遵循最终 Runtime gate。需要确认时先说明对象、动作和影响,用户确认后才追加 `--yes`;不要把 `--yes` 写进存储示例。
|
||||
- 写后必须核验。非幂等写超时、缺少稳定 ID 或读回失败时先对账,禁止盲目重放。
|
||||
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcuts(无专用脚本/recipe 时优先)
|
||||
@@ -44,80 +46,50 @@ metadata:
|
||||
| `dws todo +update` | write | 更新待办并读回验证 |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
## 路由优先级
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "建一条待办给张三" | `dws todo task create --title "<标题>" --executors <userId>` |
|
||||
| "较高 / 高优先级待办" | `dws todo task create ... --priority 30`(10低/20普通/30较高/40紧急) |
|
||||
| "紧急 / 最高优先级 / 立即处理" | `dws todo task create ... --priority 40` |
|
||||
| "循环待办(每天)" | `dws todo task create ... --due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"` |
|
||||
| "批量建待办" | 按 SOP-4 逐条创建、收集 `taskId` 并批量回读 |
|
||||
| "今天 / 本周未完成待办" | `python scripts/todo_daily_summary.py [today\|tomorrow\|week]` |
|
||||
| "逾期待办" | `python scripts/todo_overdue_check.py` |
|
||||
| "标记完成 / 重开" | `dws todo task done --task-id <taskId> --status true\|false` |
|
||||
| "修改标题/截止时间/优先级" | `dws todo task update --task-id <taskId> ...` |
|
||||
| "删除待办" | `dws todo task delete --task-id <taskId>`(需用户确认) |
|
||||
上面的通用 shortcut 优先规则只适用于单一、完整命中的请求。本 Skill 的实际优先级是:
|
||||
|
||||
## 标准 SOP(必遵流程)
|
||||
1. **组合生命周期 → 原子命令闭环**:同一请求含创建后再列表、更新、完成、提醒、评论、附件、成员、子待办、标签或清理时,先读 [组合流程](references/02-task.md),全程使用 `todo task/comment/tag ...` 原子命令。不要用 `+remind` / `+create` 代替组合流程的第一步,也不要混用两套返回结构。
|
||||
2. **确定性批量/汇总 → 脚本**:批量创建、今天/明天/本周汇总、逾期扫描分别用 bundled scripts。
|
||||
3. **单一意图 → Shortcut**:只在一个 shortcut 已经覆盖完整目标、校验和结果形态时直接使用。
|
||||
4. **未知低频能力 → 精确 Reference/Help**:只读当前操作的小节,不要枚举后试错。
|
||||
|
||||
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 taskId。每条命令必须带 `--format json`。创建/完成/删除后**必须**回读验证,不要凭创建返回或口头计划就结束。
|
||||
## 高频路线
|
||||
|
||||
### SOP-1 建待办(create-todo)
|
||||
| 用户意图 | 首选入口 | 边界 |
|
||||
|---|---|---|
|
||||
| 给自己创建一条普通待办 | `dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json` | `--at` 是截止时间,不是独立提醒 |
|
||||
| 按姓名给一人/多人指派一条待办 | `+assign` / `+assign-multi` | 任一姓名不唯一就停止,不能猜 `userId` |
|
||||
| 已有 `userId`,只创建并回读一条待办 | `dws todo +create --title "<标题>" --executors <USER_ID> ... --format json` | 结果必须含稳定 `taskId` 且读回一致 |
|
||||
| 创建后还要做其他操作 | `dws todo task create ... --format json` | 从 `result.taskId` 进入同一原子命令闭环 |
|
||||
| 按状态、优先级、角色、日期或页码枚举 | `dws todo task list ... --format json` | `--status false/true`;`hasMore=true` 继续翻页 |
|
||||
| 当前组织我的待办 / 与我相关的全部待办 | `+get-my-tasks` / `+get-related-tasks` | 后者是创建人、执行人、参与人并集 |
|
||||
| 按标题关键词定位 / 已知 ID 查详情 | `+search --query ...` / `+get --task-id ...` | 零个或多个候选时停止消歧 |
|
||||
| 已知 ID 完成、重开、更新 | `+complete` / `+reopen` / `+update` | shortcut 会做状态检查或读回核验 |
|
||||
| 今天到期 / 逾期 | `+due-today` / `+overdue` | 空集合也是成功结果 |
|
||||
| 设置或清除独立提醒 | `+reminder` | 上游无提醒查询接口,只能报告写回执,不能声称读回 |
|
||||
|
||||
**触发**:建待办/任务提醒/指派任务/TODO。
|
||||
## 组合任务闭环
|
||||
|
||||
1. **解析执行者(必须)**:指定姓名 → `dws aisearch person --query "<姓名>" --dimension name --format json` 取 `userId`;未指定 → `dws contact user get-self --format json` 取当前用户 `userId`;多人逐个搜索后英文逗号拼接。
|
||||
2. **执行(必须)**:`dws todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40> --format json`;有截止时间加 `--due "<ISO>"`;循环待办加 `--due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"`。
|
||||
3. **验证(必须)**:从返回取 `taskId`/`todoTaskId`,立即 `dws todo task get --task-id <taskId> --format json` 回读。
|
||||
1. 执行前列出用户要求的全部资源动作及顺序;同一链路使用同一个 profile。
|
||||
2. 原子创建必须先取得执行人:未指定执行人用 `dws contact me --format json`;指定姓名用 `dws aisearch person --query "<姓名>" --dimension name --format json` 并唯一匹配。
|
||||
3. 创建后只从 `result.taskId` 提取稳定 ID。后续评论、附件和标签编号分别来自 `comment list`、`task list-attachment`、`tag create/list` 的真实返回。
|
||||
4. 每次写后使用对应 read/list 核验;提醒例外,只保留终端写回执。最后仅清理本次创建且已经记录 ID 的对象。
|
||||
|
||||
**禁止**:跳过执行者解析直接传姓名、用 `task detail` 取详情(正确是 `task get`)、创建后不回读。
|
||||
## 关键约束
|
||||
|
||||
### SOP-2 查询待办(query-todo)
|
||||
- 公开命令统一使用 `--task-id`;不要在新命令中使用隐藏兼容别名 `--id` / `--ids`。
|
||||
- 优先级:低=10、普通=20、较高/高/重要=30、紧急/最高/P0=40。
|
||||
- `--due` 与 `+remind --at` 表示 deadline;独立 reminder 用 `+reminder` 或原子 `task add-reminder`。
|
||||
- `task list` 用 `--status`,不要写 `--done`;详情命令是 `task get`,不存在 `task detail`。
|
||||
- “待办标签”始终使用 `dws todo tag ...`,绝不能解释为 Git tag、文档标签或其他产品标签。
|
||||
- 本地文件作为待办附件时使用 `task add-attachment --file <绝对路径>`;先确认待办存在,不得用上传动作试探权限。
|
||||
- 会后行动项先走 `dingtalk-minutes` 取真实内容;OA 审批走 `dingtalk-misc`;时间块和会议走 `dingtalk-calendar`。
|
||||
|
||||
**触发**:查待办/今天本周待办/未完成/已完成。
|
||||
## 按需参考
|
||||
|
||||
1. **执行(必须)**:`dws todo task list --status false|true --format json`(`false`=未完成、`true`=已完成、不传=全部);`hasMore=true` 必须翻页。
|
||||
2. **摘要脚本(必须)**:今天/本周未完成 → `python scripts/todo_daily_summary.py today|tomorrow|week`;逾期 → `python scripts/todo_overdue_check.py`。
|
||||
3. **详情(必须)**:`dws todo task get --task-id <taskId> --format json`;按主题筛选先 `task list` 再按标题过滤,**禁止**编造主题查询 flag。
|
||||
|
||||
**禁止**:写 `--done true`(用 `--status true`)、编造主题筛选参数。
|
||||
|
||||
### SOP-3 完成 / 重开 / 改 / 删(mutate-todo)
|
||||
|
||||
**触发**:标记完成/重开/改标题截止优先级/删待办。
|
||||
|
||||
1. **执行(必须)**:完成/重开 `dws todo task done --task-id <taskId> --status true|false --format json`;修改 `dws todo task update --task-id <taskId> ...`;删除 `dws todo task delete --task-id <taskId>`(**必须**先与用户确认)。
|
||||
2. **验证(必须)**:`task done`/`update` 后用 `task get` 或对应 `task list --status ...` 回读确认;`delete` 后用 `task get` 确认已不存在或列表已移除。
|
||||
|
||||
**禁止**:未确认就删除、用 `update --done`(首选 `task done --status`)、改动后不回读。
|
||||
|
||||
### SOP-4 批量建待办(batch-create)
|
||||
|
||||
**触发**:批量建待办/一次建多条。
|
||||
|
||||
1. **解析(必须)**:执行者姓名先批量解析成真实 `userId`;单批最多 30 条。
|
||||
2. **执行(必须)**:对每条待办执行 `dws todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40> [--due "<ISO>"] --format json`,逐条收集返回的 `taskId`/`todoTaskId`。可并行执行,但不得丢失“输入条目 → taskId”对应关系。
|
||||
3. **验证(必须)**:对全部新建 `taskId` 执行 `dws todo task get --task-id <taskId> --format json` 回读;多 ID 按共享并行规则处理,全部成功后才能报告批量创建完成。
|
||||
|
||||
**禁止**:只统计创建命令退出码、不保留 taskId、创建后不回读、在执行者位置传姓名。
|
||||
|
||||
## 参数硬约束
|
||||
|
||||
- 任务详情只用 `dws todo task get --task-id <taskId>`;不要写 `task detail`。
|
||||
- 完成状态首选 `dws todo task done --task-id <taskId> --status true|false`;若用 `update`,也必须是 `--task-id` + `--done true|false`。
|
||||
- 查询列表完成状态用 `dws todo task list --status false|true --format json`。不要写 `--done true` 作为可见参数,虽然兼容但不作为推荐写法。
|
||||
- `--id` / `--ids` 是隐藏兼容别名,文档和生成命令统一写 `--task-id`,减少模型漂移。
|
||||
- 优先级映射:低=10,普通=20,较高/高/重要=30,紧急/最高/P0/马上处理=40;不要把"较高"写成 40。
|
||||
- 截止时间必须是 ISO-8601。相对日期按当前日期计算;例如周五说"下周二"就是紧接下一个自然周的周二,不要再加一周。
|
||||
- 创建、标记完成、重开、删除后必须 `task get` 或对应 `task list --status ...` 验证,不要只凭创建返回或口头计划结束。
|
||||
- 所有 dws 命令带 `--format json`。
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 执行人是人名 → 先用 `dingtalk-aisearch` 拿 `userId`
|
||||
- 会后从听记自动建待办 → 切到 `dingtalk-minutes`
|
||||
- 项目进度汇总写文档 → 切到 `dingtalk-doc`
|
||||
## 局部意图与短流程
|
||||
|
||||
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
|
||||
- [单步与短流程](references/lite-recipes.md)
|
||||
- [组合生命周期与动态 ID 传递](references/02-task.md)
|
||||
- [局部意图消歧](references/intent-guide.md)
|
||||
- [完整原子命令参考](references/todo.md)
|
||||
|
||||
@@ -1,19 +1,61 @@
|
||||
# 任务管理
|
||||
# Todo 组合生命周期
|
||||
|
||||
> **SKILL.md** 中 #2 仅内联 **lite**:`create-todo`、`todo-query-ops`。其中 `todo-query-ops` 统一覆盖 list/get/complete/reopen/topic-filter。下列 recipe 已迁出速查表,命中时读本文件对应行。重型 **full** 见下表「行动指南」。命令细节见 [todo.md](./todo.md)。
|
||||
当一个请求包含两个以上资源动作,特别是“创建 → 操作 → 查看 → 清理”时,使用本文件。组合链路统一走原子命令,不把 shortcut 与原子返回结构混在一起。
|
||||
|
||||
## Recipe 速查(非 SKILL lite)
|
||||
## 通用骨架
|
||||
|
||||
| Recipe | 步骤(命令均须 `--format json`,下略) |
|
||||
|--------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读标题、执行者、优先级和截止时间 |
|
||||
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --priority <10/20/30/40> --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](./todo.md))→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读循环规则和任务字段 |
|
||||
| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`<br>2. `todo task update --task-id <todoTaskId> --due "<新截止时间>"` |
|
||||
1. 先列出完整动作序列和需要传递的 ID,不要边执行边发现路线。
|
||||
2. 未指定执行人时运行 `dws contact me --format json`;指定姓名时运行 `dws aisearch person --query "<姓名>" --dimension name --format json`,唯一匹配后取 `userId`。
|
||||
3. 创建父待办:
|
||||
|
||||
## Full / 组合(固定路线)
|
||||
```bash
|
||||
dws todo task create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] [--recurrence "<规则>"] --format json
|
||||
```
|
||||
|
||||
| Recipe | 行动指南(固定路线) |
|
||||
|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| generate-progress-report | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行<br>2. 交叉比对各源数据<br>3. `doc create --name "<报告名>" --content "<报告内容>"` |
|
||||
| batch-create-todo | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行 → 从结果提取任务条目<br>2. 每条:`aisearch person --query "<姓名>" --dimension name` → 取真实 `userId`;同名时先消歧<br>3. 逐条执行 `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>`,从每次响应收集真实 `todoTaskId`;单批超 30 条须用户确认<br>4. 对全部 `todoTaskId` 并行执行 `todo task get --task-id <todoTaskId>`,逐项核对标题、执行者、优先级和截止时间;不能只以退出码或创建响应作为成功证据 |
|
||||
| assign-and-notify | 1. `aisearch person --query "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>` → 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读任务,确认无误后再通知<br>4. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --conversation-id <openConversationId> --content "<通知内容>"` |
|
||||
4. 只从成功响应的 `result.taskId` 取 ID,立即 `dws todo task get --task-id <TASK_ID> --format json` 核验。
|
||||
5. 按下表执行后续动作;每一步都复用真实 ID,并用对应读取命令核验。
|
||||
6. 只清理本次创建且已记录 ID 的对象;删除类操作先走 Runtime 确认门。删除后 `task get` 不存在或列表移除才算清理完成。
|
||||
|
||||
## 原子路线表
|
||||
|
||||
| 意图 | 写命令 | 核验 / ID 来源 |
|
||||
|---|---|---|
|
||||
| 按状态/优先级/角色/日期查询 | `task list --status ... --priority ... --role-types ... --plan-finish-date-start ... --plan-finish-date-end ...` | 遍历 `result.todoCards[]`;`hasMore=true` 时递增 `--page` |
|
||||
| 更新标题/优先级/截止时间 | `task update --task-id <TASK_ID> ...` | `task get --task-id <TASK_ID>` 逐字段核验 |
|
||||
| 完成 / 重开 | `task done --task-id <TASK_ID> --status true|false` | `task get` 或对应状态的 `task list` |
|
||||
| 创建子待办 | `task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID>` | 取响应 `result.taskId`;`task list-sub --task-id <PARENT_ID>` |
|
||||
| 增删执行人 | `task add-executor --task-id <TASK_ID> --executors <USER_ID>` / `task remove-executor --task-id <TASK_ID> --executors <USER_ID>` | `task get`;只移除本次明确添加的人 |
|
||||
| 增删参与人 | `task add-participant --task-id <TASK_ID> --participants <USER_ID>` / `task remove-participant --task-id <TASK_ID> --participants <USER_ID>` | `task get`;执行人与参与人不可混用 |
|
||||
| 添加评论 | `comment add --task-id <TASK_ID> --content "<内容>"` | 从 `comment list` 的 `result.comments[].id` 取真实评论 ID 并核对内容 |
|
||||
| 删除评论 | `comment delete --task-id <TASK_ID> --comment-id <COMMENT_ID>` | 再次 `comment list` 确认目标不存在 |
|
||||
| 上传附件 | `task add-attachment --task-id <TASK_ID> --file <绝对路径>` | `task list-attachment` 取真实 `attachmentId` 并核对文件名 |
|
||||
| 移除附件 | `task remove-attachment --task-id <TASK_ID> --attachment-id <ATTACHMENT_ID>` | 再次 `task list-attachment` |
|
||||
| 添加截止前提醒 | `task add-reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30` | 只有终端写回执;待办必须已有截止时间 |
|
||||
| 添加独立提醒 | `task add-reminder --task-id <TASK_ID> --base-time customTime --reminder-time-stamp "<提醒ISO>"` | 只有终端写回执 |
|
||||
| 替换/清空提醒 | `task reset-reminder --task-id <TASK_ID> [--reminder-rules '<JSON数组>']` | 不传规则即清空;不能声称已读回规则 |
|
||||
| 创建/列出标签 | `tag create --name "<名称>"` / `tag list` | 创建前后比较 `result.userTags[]`,只接受唯一新增的 `code` |
|
||||
| 关联标签 | `tag add --task-id <TASK_ID> --tag-codes <TAG_CODE_1>[,<TAG_CODE_2>]` | `task get` 或写回执;最多两个标签 |
|
||||
| 改名标签 | `tag update --user-tags '[{"code":"<TAG_CODE>","name":"<新名称>"}]'` | `tag list` 核对同一 `code` 的名称 |
|
||||
| 删除标签定义 | `tag delete --tag-codes <TAG_CODE>` | `tag list` 确认标签定义不存在 |
|
||||
| 删除待办 | `task delete --task-id <TASK_ID>` | `task get` 不存在或对应列表已移除 |
|
||||
|
||||
所有表中命令都加前缀 `dws todo` 和后缀 `--format json`。
|
||||
|
||||
## 动态 ID 账本
|
||||
|
||||
| ID | 只允许来自 | 禁止来源 |
|
||||
|---|---|---|
|
||||
| `taskId` | `task create/create-sub/get/list` 的业务返回 | 标题、URL、展示序号、其他 case |
|
||||
| `commentId` | 同一 `taskId` 的 `comment list` 返回 `result.comments[].id` | 评论文本或猜测 |
|
||||
| `attachmentId` | 同一 `taskId` 的 `task list-attachment` 返回 `attachments[].attachmentId` | 文件名或本地路径 |
|
||||
| `tagCode` | `todo tag create/list` 返回 `result.userTags[].code` | 标签名或 Git tag |
|
||||
| `userId` | `contact me` 或 `aisearch person` 的唯一匹配 | 姓名、手机号片段、其他 profile |
|
||||
|
||||
“待办标签”只能调用 `dws todo tag ...`;禁止运行 `git tag`。本地待办附件只能调用 `dws todo task add-attachment`,不要改走 Drive。
|
||||
|
||||
## 失败与恢复
|
||||
|
||||
- `unknown command/flag`:查该 leaf 的 `--help` 后最多修正一次,不要轮询相似命令。
|
||||
- 创建、评论等非幂等写超时或返回不明:保留“可能已提交”,先按标题/父 ID 查询对账,不自动重试。
|
||||
- 提醒接口没有查询能力:成功时只报告服务端接受写入;失败时保留原错误。
|
||||
- 任一步失败后仍要按账本清理已创建的临时对象;未取得稳定 ID 的对象不得猜 ID 清理。
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# todo 局部意图消歧
|
||||
# Todo 局部意图消歧
|
||||
|
||||
本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。
|
||||
| 用户说 | 应该用 | 不要用 | 边界 |
|
||||
|---|---|---|---|
|
||||
| “帮我记一下明天要做的事” | `todo +remind` | `doc` | 这是个人待办;`--at` 写截止时间 |
|
||||
| “给自己留一个明天下午的时间块” | `calendar event create` | `todo` | 时间块属于日历事件 |
|
||||
| “明早 9 点提醒我提交周报” | 先创建待办,再用 `todo +reminder --base-time customTime --at ...` | 把 `+remind --at` 当提醒 | 独立提醒与截止时间是两个字段 |
|
||||
| “截止前 30 分钟提醒” | `todo +reminder --base-time dueTime --due-date-offset -30` | `calendar` | 待办必须先有截止时间 |
|
||||
| “每天重复提醒我” | `todo task create --due ... --recurrence ...`,必要时再加 reminder | 只写 reminder | recurrence 管重复待办;reminder 管单条待办提醒 |
|
||||
| “创建/修改/删除待办标签” | `dws todo tag ...` | `git tag`、文档标签 | 标签名不是 `tagCode`,后续使用真实返回 |
|
||||
| “提交日报/周报” | `dingtalk-misc` | `todo` | 日志产品不是个人待办 |
|
||||
| “审批这个申请” | `dingtalk-misc` | `todo` | OA 审批任务与个人待办不同 |
|
||||
| “把会议行动项建成待办” | 先用 `dingtalk-minutes` 取真实行动项,再进入 Todo | 凭标题猜行动项 | 来源证据归听记,任务对象归 Todo |
|
||||
|
||||
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|
||||
|---|---|---|---|---|
|
||||
| "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `doc` | 个人待办提醒,非文档内容 |
|
||||
| "给自己留一个明天下午的时间块/建个个人日程" | 创建个人日程 | `calendar event create` | `todo` | 个人 schedule 仍属于日历事件,不是待办 |
|
||||
| "明早 9 点提醒我提交周报" | 创建个人待办,但需先声明 reminder 边界 | `todo` | `calendar` | todo 当前只支持 dueTime 截止时间,不支持独立精确 reminder |
|
||||
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
|
||||
| "把最近几次关于XX的会议汇总成报告" | 按主题汇总多次听记 | #5 generate-topic-report | #7 meeting-followup | #7 是单次会议听记跟进;多次会议按主题汇总属于工作汇报 |
|
||||
提醒写入目前没有对应查询接口,成功响应只能证明服务端接受了写请求,不能声称已经读回核验规则。
|
||||
|
||||
@@ -1,151 +1,57 @@
|
||||
# todo Lite Recipe
|
||||
# Todo 单步与短流程
|
||||
|
||||
本文件从单 Skill `lite-recipes.md` 拆分而来,仅保留与本产品相关的轻量流程。
|
||||
只在请求是单一 Todo 意图时使用本文件;创建后还要继续操作资源时,改读 [02-task.md](02-task.md)。
|
||||
|
||||
## #2 任务管理
|
||||
## 创建一条待办
|
||||
|
||||
### create-todo
|
||||
|
||||
1. 确定执行者:指定姓名 → `aisearch person --query "<姓名>" --dimension name` → `userId`;未指定 → `contact user get-self` → `userId`;多人 → 逐个搜索逗号拼接。
|
||||
2. 创建:`todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <优先级>`(可选 `--due "<截止ISO>"`)→ `todoTaskId`
|
||||
|
||||
### todo-query-ops
|
||||
|
||||
- 查询:`todo task list [--status false|true]`(不传=全部)
|
||||
- 详情:`todo task get --task-id <id>`
|
||||
- 完成/重开:`todo task done --task-id <id> --status <true|false>`
|
||||
- 按主题筛选:list 后按标题关键词过滤
|
||||
|
||||
|
||||
## #7 听记与会后
|
||||
|
||||
> 产品命令完整参考见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。full recipe 见 [07-minutes.md](../../dingtalk-minutes/references/07-minutes.md)。
|
||||
|
||||
### minutes-query(查询与获取)
|
||||
|
||||
> **scope 选择铁律(P2 真实 badcase)**:`list` 后的 scope 决定查询范围,最高频误判是把"我能访问的所有听记"错选成 `mine`:
|
||||
> - `mine` = **仅我自己创建/发起**的听记(范围最窄)
|
||||
> - `shared` = **仅他人共享给我**的听记
|
||||
> - `all` = **我可访问的全部**(= mine ∪ shared,范围最广)
|
||||
> - **判定口诀**:query 含"访问/权限/可见/能看到/所有/我的"等覆盖范围语义 → 一律走 `all`;**仅当**明确说"我创建的/我发起的/我录的" → 才走 `mine`。**不要因为句子里有"我"字就退化成 `mine`。**
|
||||
> - 错误:`我能访问的所有听记` → `list mine`(漏掉共享给我的,判定不通过)
|
||||
> - 正确:`我能访问的所有听记` → `dws minutes list all --format json`
|
||||
|
||||
> **选对象铁律(0605 P2 EDD badcase 提炼,命令对了但选错听记 = 整任务失败)**:list/搜索拿到结果后,必须按语义精准锁定目标听记,详见 [minutes.md](../../dingtalk-minutes/references/minutes.md)「选对象铁律 S1~S6」。速记:
|
||||
> - **S1 跨组织汇总**:以 list 返回的 `taskUuid + title + organizationName` 三元组为准逐条照抄,组织与听记不可张冠李戴。
|
||||
> - **S2 "最近一次某类会议"**:先 `--query "<主题词>"`(如周会)过滤出该类,再在候选里取时间最新;主题匹配优先级高于时间。
|
||||
> - **S3 比时长最长**:必须读 `durationMicros` 字段做数值比较,禁止凭印象/标题猜,口头结论与操作的 taskUuid 须自洽。
|
||||
> - **S4 内容为空**:锁定 taskUuid 后所有 get/update 复用同一 id;某字段为空就如实说,**禁止偷偷切换到另一条听记**。
|
||||
> - **S5 模糊日期匹配不到**:日期可能是"会议主题日期"而非"创建日期",按标题关键词搜,精确日期没命中就放宽 ±7 天/同主题候选请用户确认,**禁止直接报"找不到"**。搜索回退策略:① 先 `--query "<主题关键词>"` 不带日期搜 → ② 若结果过多则加 `--start/--end` 扩大到 ±7 天 → ③ 列出候选让用户确认。
|
||||
> - **S6 给标题没给 id**:必须先 `list all --query "<标题关键词>"` 定位 taskUuid 再 update/get,禁止凭记忆直接填 `--id` 跳过定位。
|
||||
|
||||
**列表查询**(`list` 后**必须**跟 scope:`mine`/`shared`/`all`,默认补 `all`):
|
||||
给自己:
|
||||
|
||||
```bash
|
||||
# 我可访问的所有听记(默认)
|
||||
dws minutes list all --format json
|
||||
# 按关键词服务端搜索(严禁全量拉取后本地 grep)
|
||||
dws minutes list all --query "周会" --format json
|
||||
# 按时间范围筛选(ISO-8601 格式)
|
||||
dws minutes list mine --start "2026-05-01T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
|
||||
# 关键词 + 时间组合
|
||||
dws minutes list all --query "需求评审" --start "2026-05-25T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
|
||||
# 限制条数
|
||||
dws minutes list mine --limit 5 --format json
|
||||
# 共享给我的听记
|
||||
dws minutes list shared --query "ROI" --format json
|
||||
dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--query "<关键词>"` | 服务端关键词搜索 |
|
||||
| `--start "<ISO-8601>"` | 开始时间 |
|
||||
| `--end "<ISO-8601>"` | 结束时间 |
|
||||
| `--limit <N>` | 每页条数,默认 10(`--max` 为兼容别名) |
|
||||
| `--cursor "<token>"` | 分页 token,首页留空(`--next-token` 为兼容别名) |
|
||||
|
||||
**获取详情**:
|
||||
|
||||
- 批量基础信息:`minutes get batch --ids <uuid1,uuid2,...>`
|
||||
- 单篇摘要:`minutes get summary --id <taskUuid>`
|
||||
- 转写原文(自动翻页):`minutes get transcription --id <taskUuid>`(返回 `nextToken` 时用 `--next-token <token>` 继续)
|
||||
- 关键词:`minutes get keywords --id <taskUuid>`
|
||||
- 待办事项:`minutes get todos --id <taskUuid>`
|
||||
- 基础信息:`minutes get info --id <taskUuid>`
|
||||
- 音频地址:`minutes get audio --id <taskUuid>`
|
||||
|
||||
> `--id`/`--uuid`/`--task-uuid` 三者等价。推荐 `--id`。
|
||||
|
||||
### minutes-edit(编辑与替换)
|
||||
|
||||
- **替换转写文字**:`minutes replace-text --id <taskUuid> --search "旧文字" --replace "新文字"`
|
||||
- 执行前检查特殊字符(引号/书名号/括号等),若包含先提示用户确认去除
|
||||
- 替换成功后追问是否加热词:`minutes hot-word add --words "新文字"`
|
||||
- **替换发言人**:先统一搜人取得 dingUid → `minutes speaker replace --id <taskUuid> --from "发言人X" --to "姓名" --target-uid <userId>`
|
||||
- 查询 dingUid:`aisearch person --query "姓名" --dimension name --format json` → 取 `userId`
|
||||
- 多个匹配 → 列出候选让用户选;无匹配 → 不带 `--target-uid` 执行
|
||||
- **修改标题**:`minutes update title --id <taskUuid> --title "新标题"`
|
||||
- **修改摘要**:`minutes update summary --id <taskUuid> --content "新内容"`
|
||||
- **热词管理**:`minutes hot-word add --words "词1,词2"` / `minutes hot-word list`
|
||||
- **思维导图**:`minutes mind-graph create --id <taskUuid>` → `mind-graph status --id <taskUuid>` 轮询至完成
|
||||
|
||||
### minutes-tag(标签/分组查询)
|
||||
|
||||
- 查询标签列表:`minutes tag list` → 返回用户在听记页面创建的所有标签/分组(含 tagId 和名称)
|
||||
- 按标签查听记:`minutes tag query --tag-id <tagId> [--limit 20] [--cursor <token>]`
|
||||
- tagId 来自 `tag list` 返回值,不可编造
|
||||
- 支持分页,`--cursor` 传入上一次返回的 nextToken
|
||||
|
||||
**典型链路**:用户说"帮我看看'周会'标签下的听记" →
|
||||
1. `dws minutes tag list --format json` → 按名称匹配找到 tagId
|
||||
2. `dws minutes tag query --tag-id <tagId> --format json`
|
||||
|
||||
### minutes-permission(权限管理)
|
||||
|
||||
- 添加成员:`minutes permission add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy 4`
|
||||
- 需先通过 `aisearch person --query "<姓名>" --dimension name` 获取目标 userId
|
||||
- policy:0=不可见 / 1=仅查看 / 2=查看+下载 / 3=查看+下载+编辑 / 4=全部权限
|
||||
- 移除成员:`minutes permission remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>`
|
||||
|
||||
### minutes-upload(音频上传)
|
||||
按姓名指派:
|
||||
|
||||
```bash
|
||||
# 创建上传会话
|
||||
dws minutes upload create --file-name "meeting.mp3" --file-size 61565431 --format json
|
||||
# 上传完成后确认
|
||||
dws minutes upload complete --session-id <sid> --format json
|
||||
# 取消上传
|
||||
dws minutes upload cancel --session-id <sid> --format json
|
||||
dws todo +assign --to "<姓名>" --task "<标题>" --format json
|
||||
dws todo +assign-multi --to "<姓名1>,<姓名2>" --task "<标题>" --format json
|
||||
```
|
||||
|
||||
### 最佳实践案例速查(详见 [minutes.md](../../dingtalk-minutes/references/minutes.md))
|
||||
已经有真实 `userId`:
|
||||
|
||||
| 案例 | 场景 | 正确链路 |
|
||||
|------|------|----------|
|
||||
| 案例 1 | 听记 URL + 创建思维导图 | 提取 taskUuid → `mind-graph create` → `mind-graph status` 轮询;**禁止**走 app-development 或前端库 |
|
||||
| 案例 2 | 替换文字后未引导热词 | 检查特殊字符 → `replace-text` → 追问加热词 `hot-word add` |
|
||||
| 案例 3 | 查听记拉了不必要的转写 | 用户只要列表 → `list` 即可,**不要**自动拉 `get transcription` |
|
||||
| 案例 4 | 拉完转写只输出时间线原文 | 拉完后追问按发言人聚类 → 引导匹配 → 调用 `speaker replace` 写回 |
|
||||
| 案例 5 | 查某人说了什么不引导替换 | 推断发言人 → **用户确认** → 结构化总结 → 引导 `speaker replace` |
|
||||
| 案例 6 | 通讯录+部门+转写三路印证 | Step 3 画像 + Step 4 `aisearch person` 并发 → 置信度 ≥70% → 确认 → 替换 |
|
||||
| 案例 7 | grep 花名误判未参会 | **禁止**在转写文本里 grep 人名判参会;**必须**调 `aisearch person` |
|
||||
| 案例 8 | 听记类 query 不走 dws | **禁止**用 session_search/browser_use/activity:search 替代 dws;模糊请求先 `list mine` |
|
||||
| 案例 9 | 按标签筛选听记 | `tag list` → 按名称匹配 tagId → `tag query --tag-id <tagId>`;**禁止**编造 tagId |
|
||||
```bash
|
||||
dws todo +create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] --format json
|
||||
```
|
||||
|
||||
### 听记取数深度约束(0609 点踩 case 提炼)
|
||||
成功结果必须含稳定 `taskId` 并完成读回。超时、缺少 ID 或 `verified!=true` 时先用搜索/列表对账,禁止重放非幂等创建。
|
||||
|
||||
> 详细说明见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。
|
||||
## 查询与定位
|
||||
|
||||
- **转写原文硬约束**:用户诉求含「聚焦原话/逐字/沟通细节/具体讨论了什么」等词时,**必须先调 `get transcription` 翻页拉全**,禁止仅凭 summary 出稿
|
||||
- **数据源下钻**:听记维度**必须 `get summary`(或 `get transcription`)读正文**,严禁只取标题列表;scope 用 `all`;空时换窗重试或标注
|
||||
- **听记链接解析**:聊天消息中遇到听记链接(`flash_minutes_detail`/`SHANJI`)→ 解析 `minutesId` → 调 `minutes get summary/transcription`,禁止把链接降级为关键词
|
||||
- **忠实性约束**:源数据无某要素(行动项/责任人/数字)时禁止生成;统计字段基于实际取数计数,不得编造
|
||||
- **多源全覆盖**:用户枚举多数据源时每个来源都必须调对应工具;瞬时错误重试;如实声明缺失来源,禁编无来源数字
|
||||
```bash
|
||||
dws todo +get-my-tasks --all --status false --format json
|
||||
dws todo +get-related-tasks --format json
|
||||
dws todo +search --query "<标题关键词>" --format json
|
||||
dws todo +get --task-id <TASK_ID> --format json
|
||||
```
|
||||
|
||||
### 间接意图识别铁律
|
||||
- list 用于枚举,search 用于标题关键词,get 用于已知稳定 ID。
|
||||
- 空集合是成功;零匹配或多匹配时不得自行选第一条。
|
||||
- 用户明确要求状态、优先级、角色、日期或页码筛选时,使用 `todo task list` 的对应 flag,不要拉全后猜。
|
||||
|
||||
query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/日报/复盘/商业分析/市场感知),听记采集是**必跑前置步骤**:
|
||||
## 完成、重开与更新
|
||||
|
||||
1. **铁律 A**:任务含"会议/讨论/沟通"信息需求 → `dws minutes list` 必跑
|
||||
2. **铁律 B**:用户说"文档啥也没有" → 听记优先级更高(唯一结构化数据源)
|
||||
3. **铁律 C**:多源聚合场景 → 每个被提及的数据源都必须有采集动作,听记侧 0 调用 = 严重失败
|
||||
```bash
|
||||
dws todo +complete --task-id <TASK_ID> --format json
|
||||
dws todo +reopen --task-id <TASK_ID> --format json
|
||||
dws todo +update --task-id <TASK_ID> --title "<新标题>" --format json
|
||||
```
|
||||
|
||||
只记得标题时用 `+todo-done --task "<关键词>"`;它只在唯一命中时修改。
|
||||
|
||||
## 提醒、汇总与批量
|
||||
|
||||
- `+remind --at` 设置截止时间;独立提醒使用 `+reminder --base-time customTime --at "<提醒ISO>"`。
|
||||
- 截止前提醒使用 `+reminder --base-time dueTime --due-date-offset -30`,待办必须已有截止时间。
|
||||
- 今天/明天/本周汇总:`python scripts/todo_daily_summary.py today|tomorrow|week`。
|
||||
- 逾期扫描:`python scripts/todo_overdue_check.py`。
|
||||
- 批量创建:`python scripts/todo_batch_create.py <todos.json>`;单批最多 30 条,以逐项 ledger 为准。
|
||||
|
||||
@@ -108,7 +108,6 @@ Usage:
|
||||
dws todo task delete [flags]
|
||||
Example:
|
||||
dws todo task delete --task-id <taskId>
|
||||
dws todo task delete --task-id <taskId> --yes
|
||||
Flags:
|
||||
--task-id string 待办任务 ID (必填)
|
||||
```
|
||||
@@ -146,7 +145,6 @@ Usage:
|
||||
dws todo comment delete [flags]
|
||||
Example:
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId>
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes
|
||||
Flags:
|
||||
--task-id string 待办任务 ID (必填)
|
||||
--comment-id string 评论 ID (必填)
|
||||
@@ -237,7 +235,6 @@ Usage:
|
||||
dws todo task remove-attachment [flags]
|
||||
Example:
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId>
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes
|
||||
Flags:
|
||||
--attachment-id string 待办附件 ID (必填)
|
||||
--task-id string 待办任务 ID (必填)
|
||||
@@ -314,7 +311,6 @@ Usage:
|
||||
dws todo tag delete [flags]
|
||||
Example:
|
||||
dws todo tag delete --tag-codes code1,code2
|
||||
dws todo tag delete --tag-codes code1,code2 --yes
|
||||
Flags:
|
||||
--tag-codes string 要删除的标签编码列表,逗号分隔 (必填)
|
||||
--yes 跳过交互确认,直接执行删除
|
||||
@@ -384,7 +380,7 @@ Flags:
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 1. 创建待办 — 提取 todoTaskId
|
||||
# 1. 创建待办 — 提取 result.taskId
|
||||
dws todo task create --title "修复线上Bug" --executors userId1,userId2 \
|
||||
--priority 40 --due "2026-03-10T18:00:00+08:00" --format json
|
||||
|
||||
@@ -409,8 +405,8 @@ dws todo task update --task-id <taskId> --title "新标题" --priority 40 --form
|
||||
# 5. 标记待办完成
|
||||
dws todo task done --task-id <taskId> --status true --format json
|
||||
|
||||
# 6. 删除待办
|
||||
dws todo task delete --task-id <taskId> --yes --format json
|
||||
# 6. 删除待办(先按 Runtime gate 取得用户确认)
|
||||
dws todo task delete --task-id <taskId> --format json
|
||||
|
||||
# 7. 给待办新增评论
|
||||
dws todo comment add --task-id <taskId> --content "已开始处理" --format json
|
||||
@@ -418,8 +414,8 @@ dws todo comment add --task-id <taskId> --content "已开始处理" --format jso
|
||||
# 8. 查看待办评论列表
|
||||
dws todo comment list --task-id <taskId> --page 1 --size 20 --format json
|
||||
|
||||
# 9. 删除待办评论
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes --format json
|
||||
# 9. 删除待办评论(先按 Runtime gate 取得用户确认)
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId> --format json
|
||||
|
||||
# 10. 添加待办执行人
|
||||
dws todo task add-executor --task-id <taskId> --executors userId1,userId2 --format json
|
||||
@@ -445,8 +441,8 @@ dws todo task list-sub --task-id <taskId> --format json
|
||||
dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf --format json
|
||||
# 20. 查询待办附件列表
|
||||
dws todo task list-attachment --task-id <taskId> --format json
|
||||
# 21. 删除待办附件
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes --format json
|
||||
# 21. 删除待办附件(先按 Runtime gate 取得用户确认)
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --format json
|
||||
|
||||
# 22. 查询待办标签列表
|
||||
dws todo tag list --format json
|
||||
@@ -455,30 +451,29 @@ dws todo tag create --name "标签名" --format json
|
||||
# 24. 给待办打标签
|
||||
dws todo tag add --task-id <taskId> --tag-codes code1,code2 --format json
|
||||
# 25. 更新待办标签
|
||||
dws todo tag update --user-tags '[{"tagCode":"code1","name":"新名称"}]' --format json
|
||||
# 26. 删除待办标签
|
||||
dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
dws todo tag update --user-tags '[{"code":"code1","name":"新名称"}]' --format json
|
||||
# 26. 删除待办标签(先按 Runtime gate 取得用户确认)
|
||||
dws todo tag delete --tag-codes code1,code2 --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|---------------------------------------------|
|
||||
| `task create` | `todoTaskId` | update/done/get/delete 的 --task-id |
|
||||
| `task list` | `result[].id` | update/done/get/delete 的 --task-id |
|
||||
| `task create` | `todoTaskId` | update/done/get/delete/comment 的 --task-id |
|
||||
| `task list` | `result[].id` | update/done/get/delete/comment/add-executor/remove-executor/add-participant/remove-participant 的 --task-id |
|
||||
| `task create` / `task create-sub` | `result.taskId` | update/done/get/delete/comment 的 `--task-id` |
|
||||
| `task list` | `result.todoCards[].taskId` | update/done/get/delete/comment/member 操作的 `--task-id` |
|
||||
| `task get` | `result.todoDetailModel.subTodos[]` | 获取子待办列表,提取子待办的 `taskId` 用于后续操作 |
|
||||
| `comment list` | `result[].commentId` | `comment delete` 的 --comment-id |
|
||||
| `task list-attachment` | `result[].attachmentId` | `task remove-attachment` 的 --attachment-id |
|
||||
| `comment list` | `result.comments[].id` | `comment delete` 的 `--comment-id` |
|
||||
| `task list-attachment` | `attachments[].attachmentId` | `task remove-attachment` 的 `--attachment-id` |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
|
||||
- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
|
||||
- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒”
|
||||
- `--due` 与 `todo +remind --at` 都是截止时间;不要把它们解释成独立提醒
|
||||
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
|
||||
- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule
|
||||
- 独立提醒使用 `todo +reminder --base-time customTime --at <ISO>`,或原子命令 `task add-reminder --base-time customTime --reminder-time-stamp <ISO>`
|
||||
- 截止前提醒使用 `todo +reminder --base-time dueTime --due-date-offset <分钟>`,或对应原子命令;上游没有提醒查询接口,只能报告写回执
|
||||
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
|
||||
- `task list` 的 `--priority` 支持逗号分隔多个优先级值(如 `40,30,10`),用于同时筛选多个优先级
|
||||
- `task list` 的 `--role-types` 支持 `creator`/`executor`/`participant`,可在一次调用中同时传入多个角色用逗号分隔(如 `--role-types creator,executor`),无需分多次查询;不传时默认按 `executor` 查询
|
||||
@@ -486,7 +481,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
- todo 是个人待办管理产品
|
||||
- `task update` 可同时修改标题/优先级/截止时间/完成状态
|
||||
- `task done` 专用于修改执行者的完成状态,与 `task update --done` 作用不同
|
||||
- `task delete` 为不可逆操作,建议加 `--yes` 并与用户确认
|
||||
- `task delete` 为不可逆操作;按 Runtime gate 取得用户确认后执行
|
||||
- `comment delete` 同样为不可逆操作,执行前需用户确认;`--comment-id` 可通过 `comment list` 获取
|
||||
- `task add-executor` / `task remove-executor` 用于管理待办的执行人,`--executors` 支持逗号分隔的多个 userId
|
||||
- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId
|
||||
@@ -501,7 +496,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
- `tag add` 用于给指定待办打标签,`--task-id` 可通过 `task list` 或 `task create` 获取;`--tag-codes` 可通过 `tag list` 获取
|
||||
- `tag create` 用于创建新标签,`--name` 为标签名称 (必填)
|
||||
- `tag update` 用于更新已有标签信息,`--user-tags` 格式同 `tag create`
|
||||
- `tag delete` 用于删除标签定义,为不可逆操作,执行前需用户确认;传 `--yes` 可跳过交互提示,建议加 `--yes` 并与用户确认
|
||||
- `tag delete` 用于删除标签定义,为不可逆操作;按 Runtime gate 取得用户确认后执行
|
||||
- `tag add`(给待办打标签)与 `tag delete`(删除标签定义)作用不同:前者是关联关系,后者是删除标签本身
|
||||
|
||||
|
||||
|
||||
@@ -305,52 +305,6 @@ func TestChangelogPRContentOnlyAcceptsReleaseFragmentArchival(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyAcceptsStablePostBetaArchival(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, ".changes/1236-stable-followup.md", "---\ncategory: Fixed\n---\n\n- Stable follow-up.\n", 0o644)
|
||||
repo.commit(t, "add post-beta release fragment")
|
||||
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.1] - 2026-07-17
|
||||
|
||||
This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- Promote the complete beta release to stable.
|
||||
|
||||
### Changes since `+"`v1.0.1-beta.1`"+`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stable follow-up.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`, 0o644)
|
||||
archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1")
|
||||
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll stable archive: %v", err)
|
||||
}
|
||||
if err := os.Rename(
|
||||
filepath.Join(repo.root, ".changes", "1236-stable-followup.md"),
|
||||
filepath.Join(archiveDir, "1236-stable-followup.md"),
|
||||
); err != nil {
|
||||
t.Fatalf("Rename stable release fragment: %v", err)
|
||||
}
|
||||
repo.commit(t, "seal stable release notes")
|
||||
|
||||
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
|
||||
t.Fatalf("release fragment policy rejected stable seal: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
|
||||
t.Run("invalid active fragment", func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
|
||||
@@ -1895,85 +1895,6 @@ func TestReleasePrepareChangelogRendersAndArchivesPrereleaseFragments(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogRendersAndArchivesStableFragments(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
|
||||
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, ".changes", "1236-stable-followup.md"), []byte("---\ncategory: Fixed\n---\n\n- **Stable follow-up** (#1236) — includes a change merged after the beta.\n"), 0o644)
|
||||
r.commitAndPush(t, "install stable changelog preparation")
|
||||
|
||||
cmd := exec.Command(
|
||||
"sh",
|
||||
filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"),
|
||||
"stable",
|
||||
"v1.0.1",
|
||||
"--from-beta",
|
||||
"v1.0.1-beta.1",
|
||||
)
|
||||
cmd.Dir = r.root
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("prepare stable changelog error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
|
||||
}
|
||||
content := string(changelog)
|
||||
if !strings.Contains(content, "## [1.0.1] - 2026-07-11") ||
|
||||
!strings.Contains(content, "promotes the sealed `v1.0.1-beta.1` contents to stable") ||
|
||||
!strings.Contains(content, "### Changes since `v1.0.1-beta.1`") ||
|
||||
!strings.Contains(content, "### Fixed") ||
|
||||
!strings.Contains(content, "Stable follow-up") {
|
||||
t.Fatalf("prepared stable changelog did not render post-beta fragments:\n%s", changelog)
|
||||
}
|
||||
name := "1236-stable-followup.md"
|
||||
if _, err := os.Stat(filepath.Join(r.root, ".changes", name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unconsumed stable release fragment %s still present: %v", name, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1", name)); err != nil {
|
||||
t.Fatalf("archived stable release fragment %s missing: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogStableWithoutFragmentsKeepsTemplate(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
|
||||
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
|
||||
r.commitAndPush(t, "install stable changelog preparation")
|
||||
|
||||
cmd := exec.Command(
|
||||
"sh",
|
||||
filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"),
|
||||
"stable",
|
||||
"v1.0.1",
|
||||
"--from-beta",
|
||||
"v1.0.1-beta.1",
|
||||
)
|
||||
cmd.Dir = r.root
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("prepare stable changelog without fragments error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
|
||||
}
|
||||
content := string(changelog)
|
||||
if !strings.Contains(content, "## [1.0.1] - 2026-07-11") ||
|
||||
!strings.Contains(content, "TODO: summarize the complete user-visible release") ||
|
||||
strings.Contains(content, "### Changes since") {
|
||||
t.Fatalf("stable changelog without fragments changed template behavior:\n%s", changelog)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1")); !os.IsNotExist(err) {
|
||||
t.Fatalf("stable preparation without fragments created an archive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogRejectsInvalidReleaseFragment(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
|
||||
Reference in New Issue
Block a user