diff --git a/.changes/oa-ding-report-shortcuts.md b/.changes/oa-ding-report-shortcuts.md new file mode 100644 index 00000000..c24c62e4 --- /dev/null +++ b/.changes/oa-ding-report-shortcuts.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence. diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index a13ab19a..6e6904cb 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-19T10:35:58.304269", - "count": 423, + "generated_at": "2026-08-20T11:51:44.156046", + "count": 416, "results": [ { "suite": "semantic", @@ -2494,32 +2494,14 @@ "status": "real-ok" }, { - "suite": "read", - "service": "ding", - "command": "+list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "write", - "service": "ding", - "command": "+recall-personal", - "risk": "high-risk-write", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "ding", "command": "+receiver-status", "risk": "read", - "status": "real-ok" - }, - { - "suite": "write", - "service": "ding", - "command": "+send-personal", - "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。", + "availability": "available" }, { "suite": "semantic", @@ -3602,67 +3584,54 @@ "availability": "available" }, { - "suite": "read", - "service": "oa", - "command": "+list-cc", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-executed", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-forms", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-pending", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-submitted", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+my-initiated", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+search-forms", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "report", "command": "+inbox-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "report", "command": "+outbox-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "report", + "command": "+report-latest", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "report", + "command": "+template-search", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。", + "availability": "available" }, { "suite": "read", diff --git a/internal/app/command_path_fallback_e2e_test.go b/internal/app/command_path_fallback_e2e_test.go index 1532bd08..db8e6133 100644 --- a/internal/app/command_path_fallback_e2e_test.go +++ b/internal/app/command_path_fallback_e2e_test.go @@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t {path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}}, {path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}}, {path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}}, - {path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}}, + {path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}}, } for _, test := range tests { t.Run(test.path, func(t *testing.T) { diff --git a/internal/app/oa_shortcut_contract_test.go b/internal/app/oa_shortcut_contract_test.go new file mode 100644 index 00000000..55b9f703 --- /dev/null +++ b/internal/app/oa_shortcut_contract_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) { + snapshot := fullSchemaSnapshotForTest(t) + for _, canonical := range []string{ + "oa.shortcut_approve_by", + "oa.shortcut_done_approvals", + "oa.shortcut_list_cc", + "oa.shortcut_list_executed", + "oa.shortcut_list_forms", + "oa.shortcut_list_pending", + "oa.shortcut_list_submitted", + "oa.shortcut_my_initiated", + "oa.shortcut_pending", + "oa.shortcut_search_forms", + } { + tool, ok := snapshot.Tools[canonical] + if !ok { + t.Errorf("final Schema lacks OA tool %s", canonical) + continue + } + if got := tool["availability"]; got != contract.InterfaceAvailable { + t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable) + } + } +} diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 2d6ef0ba..cfd54910 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -226,7 +226,6 @@ 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"}, diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 82f982fb..ed89cc80 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 422 + publicShortcutCount = 415 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, - // including the hidden historical minutes.shortcut_minutes_search contract. - schemaPublishedShortcutCount = 447 + // including reviewed hidden compatibility and unavailable contracts. + schemaPublishedShortcutCount = 453 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 422 + publiclyDeliveredShortcutCount = 415 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/cli/command_path_fallbacks.json b/internal/cli/command_path_fallbacks.json index fdd58095..f2b3c930 100644 --- a/internal/cli/command_path_fallbacks.json +++ b/internal/cli/command_path_fallbacks.json @@ -191,12 +191,12 @@ "from": "oa +list-processes", "mode": "ambiguous", "candidates": [ - "oa +list-forms", - "oa +my-initiated", + "oa +search-forms", + "oa approval list-submitted", "oa approval list-initiated" ], "reviewed": true, - "review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf." + "review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes." }, { "from": "chat +conversation-detail", diff --git a/internal/cli/command_path_fallbacks_generated.go b/internal/cli/command_path_fallbacks_generated.go index 83bcae9e..00f52d50 100644 --- a/internal/cli/command_path_fallbacks_generated.go +++ b/internal/cli/command_path_fallbacks_generated.go @@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{ { From: "oa +list-processes", Mode: "ambiguous", - Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}, + Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}, Reviewed: true, - ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.", + ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.", }, } diff --git a/internal/cli/command_path_fallbacks_test.go b/internal/cli/command_path_fallbacks_test.go index 0a52ca9c..1129efdd 100644 --- a/internal/cli/command_path_fallbacks_test.go +++ b/internal/cli/command_path_fallbacks_test.go @@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) { "chat +send-file": {"chat +messages-send", "chat message send"}, "chat +send-image": {"chat +messages-send", "chat message send"}, "chat +send-media": {"chat +messages-send", "chat message send"}, - "oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}, + "oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}, "doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"}, "doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"}, } diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index c64abdcb..b7bf4f50 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -6655,19 +6655,6 @@ 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{ diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index c8688ca7..0563dead 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -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","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"}, + "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"}, "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,7 +458,6 @@ {"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}, diff --git a/internal/helpers/ding.go b/internal/helpers/ding.go index 79cda622..c3db7200 100644 --- a/internal/helpers/ding.go +++ b/internal/helpers/ding.go @@ -17,6 +17,16 @@ import ( // remindType: 服务端 API 1=应用内 2=短信 3=电话 var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3} +var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"} + +func dingPersonalRemindType(value string) (string, error) { + remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))] + if !ok { + return "", fmt.Errorf("--type must be one of app, sms, call") + } + return remindType, nil +} + func newDingCommand() *cobra.Command { // Product-level Agent routing Decl (migrated from selection/ding.json // products.ding). Catalog assembly stamps provenance contract_final. @@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "users", "content"); err != nil { return err } + remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type")) + if err != nil { + return err + } users := parseCSVValues(mustGetFlag(cmd, "users")) toolArgs := map[string]any{ "receiverOpenDingTalkIds": users, "content": mustGetFlag(cmd, "content"), - "remindType": mustGetFlag(cmd, "type"), + "remindType": remindType, } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v @@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil { return err } + remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type")) + if err != nil { + return err + } users := parseCSVValues(mustGetFlag(cmd, "users")) toolArgs := map[string]any{ "openConversationId": mustGetFlag(cmd, "group"), "openMessageId": mustGetFlag(cmd, "message-id"), "receiverOpenDingTalkIds": users, - "remindType": mustGetFlag(cmd, "type"), + "remindType": remindType, } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v diff --git a/internal/helpers/ding_personal_remind_type_test.go b/internal/helpers/ding_personal_remind_type_test.go new file mode 100644 index 00000000..b94a323d --- /dev/null +++ b/internal/helpers/ding_personal_remind_type_test.go @@ -0,0 +1,18 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package helpers + +import "testing" + +func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) { + for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} { + got, err := dingPersonalRemindType(input) + if err != nil || got != want { + t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want) + } + } + if got, err := dingPersonalRemindType("push"); err == nil || got != "" { + t.Fatalf("unsupported remind type=(%q,%v)", got, err) + } +} diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go new file mode 100644 index 00000000..af96a75d --- /dev/null +++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_ding.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "ding" { + registered[item.Command] = item + } + } + if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts)) + } + wantCompatibilityVisible := map[string]bool{ + "+list": true, + "+recall-personal": true, + "+send-personal": true, + } + wantCompatibilityAvailability := map[string]shortcut.Availability{ + "+list": shortcut.AvailabilityUnavailable, + "+recall-personal": shortcut.AvailabilityAvailable, + "+send-personal": shortcut.AvailabilityAvailable, + } + public, unavailable, compatibilityVisible := 0, 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if record.CompatibilityVisible { + compatibilityVisible++ + if item.Hidden || !item.CompatibilityVisible || availability != wantCompatibilityAvailability[command] || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s compatibility-visible boundary drift", command) + } + } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s nonpublic shortcut visibility drift", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 1 || unavailable != 2 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) + } +} diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go new file mode 100644 index 00000000..9efc0bef --- /dev/null +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -0,0 +1,102 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_oa.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "oa" { + registered[item.Command] = item + } + } + if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts)) + } + wantCompatibilityVisible := map[string]bool{ + "+list-cc": true, + "+list-executed": true, + "+list-forms": true, + "+list-pending": true, + "+list-submitted": true, + "+my-initiated": true, + } + public, unavailable, compatibilityVisible := 0, 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != string(availability) { + t.Errorf("%s final interface availability=%v, want %q", command, item.Contract.Interface, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if record.CompatibilityVisible { + compatibilityVisible++ + if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityAvailable || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s compatibility-visible boundary drift", command) + } + } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s nonpublic shortcut visibility drift", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 1 || unavailable != 0 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/0/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) + } +} diff --git a/internal/shortcut/builtin/report_semantic_catalog_test.go b/internal/shortcut/builtin/report_semantic_catalog_test.go new file mode 100644 index 00000000..7e2e66de --- /dev/null +++ b/internal/shortcut/builtin/report_semantic_catalog_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_report.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "report" { + registered[item.Command] = item + } + } + if source.Service != "report" || len(registered) != 4 || len(source.Shortcuts) != 4 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want report/4/4", source.Service, len(registered), len(source.Shortcuts)) + } + public, unavailable := 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("report", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if !item.Hidden || shortcut.InPublicCatalog("report", command) { + t.Errorf("%s nonpublic shortcut is visible", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 4 || unavailable != 0 { + t.Fatalf("public/unavailable=%d/%d, want 4/0", public, unavailable) + } +} diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go new file mode 100644 index 00000000..13408567 --- /dev/null +++ b/internal/shortcut/ding/common.go @@ -0,0 +1,316 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package ding + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const ( + dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." + dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." +) + +type dingPageEvidence struct { + HasMore bool + Next string +} + +func dingReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func dingWriteSafety(destructive bool) contract.SafetySpec { + effect, risk := "write", "medium" + if destructive { + effect, risk = "destructive", "high" + } + return contract.SafetySpec{Effect: effect, Risk: risk, Confirmation: "user_required", Idempotency: "unknown"} +} + +func dingListResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的 DING 消息页","properties":{"count":{"type":"integer","description":"当前页有效 DING 消息数量"},"messages":{"type":"array","description":"严格验证并投影后的 DING 消息","items":{"type":"object","description":"一条具有稳定 openDingId 的 DING 消息","properties":{"openDingId":{"type":"string","description":"稳定 DING 消息 ID"},"content":{"type":"string","description":"DING 消息内容"},"sourceMessageId":{"type":"string","description":"作为 DING 来源的聊天消息 ID"},"sendTime":{"type":"string","description":"服务端返回的发送时间"},"senderNick":{"type":"string","description":"发送人显示名"}},"required":["openDingId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"当前页是否具有服务端分页终态证据"}},"required":["count","messages","complete"],"additionalProperties":false}`), + SensitivePaths: []string{"messages.content", "messages.senderNick"}, + } +} + +func dingReceiverResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"指定 DING 的严格接收状态","properties":{"count":{"type":"integer","description":"经验证的接收状态数量"},"receivers":{"type":"array","description":"身份匹配的 DING 接收状态","items":{"type":"object","description":"一名接收人的 DING 确认状态","properties":{"openDingId":{"type":"string","description":"与请求一致的稳定 DING ID"},"confirmedStatus":{"type":"integer","description":"服务端 DING 确认状态码"},"receiverNick":{"type":"string","description":"接收人显示名"}},"required":["openDingId","confirmedStatus","receiverNick"],"additionalProperties":false}}},"required":["count","receivers"],"additionalProperties":false}`), + SensitivePaths: []string{"receivers.receiverNick"}, + } +} + +func dingPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func dingContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "ding " + command + availability, reason := contract.InterfaceAvailable, dingCompositeReason + if !available { + availability, reason = contract.InterfaceUnavailable, dingWriteUnavailableReason + } + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "ding", Name: name, CanonicalPath: "ding." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"普通聊天消息使用 chat;写能力在缺少可逆真实 fixture、精确读回或清理证据时不要执行"}, + Examples: examples, + }, + } +} + +func dingResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func dingRequireResult(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, dingResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 DING 调用成功或结果确实为空") + } + success, present := data["success"] + if !present { + return nil, dingResponseError(operation, "missing_success", "DING 响应缺少 success 业务状态") + } + succeeded, ok := success.(bool) + if !ok { + return nil, dingResponseError(operation, "invalid_success_type", fmt.Sprintf("DING 响应 success 应为布尔值,实际为 %T", success)) + } + if !succeeded { + message := "DING 服务明确返回失败" + for _, key := range []string{"errorMsg", "errorMessage", "message"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + message = strings.TrimSpace(value) + break + } + } + return nil, dingResponseError(operation, "remote_failure", message) + } + result, ok := data["result"].(map[string]any) + if !ok || result == nil { + return nil, dingResponseError(operation, "missing_result", "DING 成功响应缺少 result 对象") + } + return result, nil +} + +func dingRequireObjectCollection(result map[string]any, operation, field string) ([]map[string]any, error) { + raw, present := result[field] + if !present { + return nil, dingResponseError(operation, "missing_collection", fmt.Sprintf("DING 成功响应缺少 result.%s 数组", field)) + } + values, ok := raw.([]any) + if !ok { + return nil, dingResponseError(operation, "malformed_collection", fmt.Sprintf("DING result.%s 应为数组,实际为 %T", field, raw)) + } + items := make([]map[string]any, 0, len(values)) + for index, value := range values { + item, ok := value.(map[string]any) + if !ok || len(item) == 0 { + return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING result.%s[%d] 不是非空对象", field, index)) + } + items = append(items, item) + } + return items, nil +} + +func dingRequiredString(item map[string]any, operation, field string, index int) (string, error) { + value, ok := item[field].(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return "", dingResponseError(operation, "missing_item_identity", fmt.Sprintf("DING 结果第 %d 项缺少非空 %s", index, field)) + } + return value, nil +} + +func dingOptionalString(item map[string]any, field string) (string, error) { + value, present := item[field] + if !present || value == nil { + return "", nil + } + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("字段 %s 应为字符串,实际为 %T", field, value) + } + return strings.TrimSpace(text), nil +} + +func dingInteger(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 { + return 0, false + } + return int64(typed), true + case int: + return int64(typed), true + case int64: + return typed, true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + default: + return 0, false + } +} + +func dingProjectMessages(data map[string]any, operation string) ([]map[string]any, dingPageEvidence, error) { + result, err := dingRequireResult(data, operation) + if err != nil { + return nil, dingPageEvidence{}, err + } + items, err := dingRequireObjectCollection(result, operation, "dingMessages") + if err != nil { + return nil, dingPageEvidence{}, err + } + messages := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := dingRequiredString(item, operation, "openDingId", index) + if identityErr != nil { + return nil, dingPageEvidence{}, identityErr + } + if _, duplicate := seen[id]; duplicate { + return nil, dingPageEvidence{}, dingResponseError(operation, "duplicate_item_identity", "当前 DING 页包含重复 openDingId") + } + seen[id] = struct{}{} + projected := map[string]any{"openDingId": id} + for source, target := range map[string]string{ + "dingContent": "content", "dingSourceOpenMessageId": "sourceMessageId", + "sendTime": "sendTime", "senderNick": "senderNick", + } { + value, valueErr := dingOptionalString(item, source) + if valueErr != nil { + return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 结果第 %d 项%s", index, valueErr.Error())) + } + if value != "" { + projected[target] = value + } + } + messages = append(messages, projected) + } + hasMoreValue, present := result["hasMore"] + if !present { + return nil, dingPageEvidence{}, dingResponseError(operation, "missing_pagination", "DING 列表缺少 result.hasMore,不能宣称当前页完整") + } + hasMore, ok := hasMoreValue.(bool) + if !ok { + return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_pagination", "DING result.hasMore 应为布尔值") + } + page := dingPageEvidence{HasMore: hasMore} + if !hasMore { + if next, exists := result["nextCursor"]; exists && next != nil { + if numeric, valid := dingInteger(next); !valid || numeric != 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 仍表示后续页") + } + } + return messages, page, nil + } + if len(messages) == 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "empty_page_with_continuation", "DING 空页仍声明 hasMore=true,无法证明游标可安全推进") + } + next, valid := dingInteger(result["nextCursor"]) + if !valid || next <= 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 nextCursor") + } + page.Next = strconv.FormatInt(next, 10) + return messages, page, nil +} + +func dingProjectReceivers(data map[string]any, operation, expectedID string) ([]map[string]any, error) { + result, err := dingRequireResult(data, operation) + if err != nil { + return nil, err + } + items, err := dingRequireObjectCollection(result, operation, "receivers") + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, dingResponseError(operation, "empty_receiver_status", "DING 接收状态显式为空,无法证明目标 DING 的接收状态") + } + receivers := make([]map[string]any, 0, len(items)) + for index, item := range items { + id, identityErr := dingRequiredString(item, operation, "openDingId", index) + if identityErr != nil { + return nil, identityErr + } + if id != expectedID { + return nil, dingResponseError(operation, "identity_mismatch", "DING 接收状态的 openDingId 与请求目标不一致") + } + status, ok := dingInteger(item["confirmedStatus"]) + if !ok { + return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 接收状态第 %d 项 confirmedStatus 不是整数", index)) + } + nick, nickErr := dingRequiredString(item, operation, "receiverNick", index) + if nickErr != nil { + return nil, nickErr + } + receivers = append(receivers, map[string]any{"openDingId": id, "confirmedStatus": status, "receiverNick": nick}) + } + return receivers, nil +} + +func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page dingPageEvidence) error { + payload := map[string]any{"count": len(messages), "messages": messages, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextCursor"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return dingResponseError("im/list_ding_messages", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(messages)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func dingUnavailable(operation string) error { + return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行", + apperrors.WithOperation(operation), + apperrors.WithOrigin("shortcut_registry"), + apperrors.WithFailureStage("capability_gate"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithReason("write_fixture_unavailable"), + ) +} diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index e5e3bb2b..ba2a19a5 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -1,226 +1,205 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package ding holds the built-in DING (钉) shortcuts. Tool names and params are -// copied verbatim from internal/helpers/ding.go. Some tools route to the "im" -// MCP server (helper uses callMCPToolOnServer("im", ...)), reflected in Product. +// Package ding registers strict declarative shortcuts for DingTalk DING. package ding import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "strconv" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) var List = shortcut.Shortcut{ Service: "ding", Command: "+list", Product: "im", - Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_list", - CanonicalPath: "ding.shortcut_list", - CLIPath: "ding +list", - PrimaryCLIPath: "ding +list", - }, - Description: "查询 DING 消息列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "查询 DING 消息列表", - UseWhen: []string{"当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +list"}, - }, - }, + Description: "查询 DING 消息列表", + Intent: "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。", + Risk: shortcut.RiskRead, + Safety: dingReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract( + "+list", "查询 DING 消息列表", + "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。", + true, dingListResult(), dingPagination(), + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "type", Property: "type"}}, + "dws ding +list --type ALL", "dws ding +list --type DELETED", + ), Flags: []shortcut.Flag{ - {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"}, - {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "类型 (可选,默认 ALL)"}, + {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"}, + {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型:ALL、UNREAD、SEND、NEW_COMMENT 或 DELETED"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}}, + Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("cursor") < 0 { + return apperrors.NewValidation("--cursor 不能小于 0") + } + return nil }, Execute: func(rt *shortcut.RuntimeContext) error { - // type 是服务端必填,空值会报错;不传或传空时兜底为 ALL(对齐 helper)。 - msgType := rt.Str("type") - if msgType == "" { - msgType = "ALL" - } - params := map[string]any{"type": msgType} + params := map[string]any{"type": rt.Str("type")} if rt.Changed("cursor") { params["cursor"] = rt.Int("cursor") } - return rt.CallMCP("list_ding_messages", params) + data, err := rt.CallMCPData("im", "list_ding_messages", params) + if err != nil { + return err + } + messages, page, err := dingProjectMessages(data, "im/list_ding_messages") + if err != nil { + return err + } + if page.HasMore { + current := int64(rt.Int("cursor")) + next, parseErr := strconv.ParseInt(page.Next, 10, 64) + if parseErr != nil || next <= current { + return dingResponseError("im/list_ding_messages", "stalled_cursor", "nextCursor 没有严格前进") + } + } + return outputDingPage(rt, messages, page) }, } var ReceiverStatus = shortcut.Shortcut{ Service: "ding", Command: "+receiver-status", Product: "im", - Description: "查询 DING 消息接收人已读状态", Intent: "当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。", Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_receiver_status", - CanonicalPath: "ding.shortcut_receiver_status", - CLIPath: "ding +receiver-status", - PrimaryCLIPath: "ding +receiver-status", - }, - Description: "查询 DING 消息接收人已读状态", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "查询 DING 消息接收人已读状态", - UseWhen: []string{"当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +receiver-status --ding-id "}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, - }, + Description: "查询 DING 消息接收人已读状态", + Intent: "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", + Risk: shortcut.RiskRead, + Safety: dingReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract( + "+receiver-status", "查询 DING 消息接收人已读状态", + "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", + true, dingReceiverResult(), nil, + []contract.ParamDecl{{Name: "ding-id", Property: "dingId"}}, + "dws ding +receiver-status --ding-id ", + ), + Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, + Tips: []string{"dws ding +receiver-status --ding-id "}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("list_ding_receiver_status", map[string]any{ - "openDingId": rt.Str("ding-id"), - }) + const operation = "im/list_ding_receiver_status" + data, err := rt.CallMCPData("im", "list_ding_receiver_status", map[string]any{"openDingId": rt.Str("ding-id")}) + if err != nil { + return err + } + receivers, err := dingProjectReceivers(data, operation, rt.Str("ding-id")) + if err != nil { + return err + } + payload := map[string]any{"count": len(receivers), "receivers": receivers} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(receivers))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) }, } -var SendPersonal = shortcut.Shortcut{ - Service: "ding", Command: "+send-personal", Product: "im", - Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite, - Safety: contract.SafetySpec{ - Effect: "write", Risk: "medium", - Confirmation: "user_required", Idempotency: "unknown", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_send_personal", - CanonicalPath: "ding.shortcut_send_personal", - CLIPath: "ding +send-personal", - PrimaryCLIPath: "ding +send-personal", - }, - Description: "以本人身份发送 DING 给指定人", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "以本人身份发送 DING 给指定人", - UseWhen: []string{"当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +send-personal --users --content "}, - }, - }, - Flags: []shortcut.Flag{ +var SendPersonal = compatibilityDingWrite( + "+send-personal", "以本人身份发送 DING 给指定人", + "仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。", + shortcut.RiskWrite, false, + []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, - {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式:app、sms 或 call"}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { + []contract.ParamDecl{ + {Name: "users", Property: "users", InterfaceType: "array"}, + {Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"}, + }, + "dws ding +send-personal --users --content ", + func(rt *shortcut.RuntimeContext) error { + remindType, err := dingPersonalRemindType(rt.Str("type")) + if err != nil { + return err + } params := map[string]any{ "receiverOpenDingTalkIds": rt.StrSlice("users"), "content": rt.Str("content"), - "remindType": rt.Str("type"), + "remindType": remindType, } if rt.Changed("uuid") { params["uuid"] = rt.Str("uuid") } return rt.CallMCP("send_personal_ding", params) }, -} +) -var SendByMessage = shortcut.Shortcut{ - Service: "ding", Command: "+send-by-message", Product: "im", - Description: "针对某条消息发起 DING 提醒", Intent: "当群里已有一条聊天消息需要被重点跟进,你想直接把它变成 DING 去强提醒相关人(而不是另写新内容)时使用;需提供群 openConversationId、该消息 openMessageId 和接收人 openDingTalkId 列表,会真实基于这条消息发出 DING。", Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ +var SendByMessage = unavailableDingWrite( + "+send-by-message", "针对某条消息发起 DING 提醒", + "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。", + shortcut.RiskWrite, false, + []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, {Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true}, {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, - {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "openConversationId": rt.Str("group"), - "openMessageId": rt.Str("message-id"), - "receiverOpenDingTalkIds": rt.StrSlice("users"), - "remindType": rt.Str("type"), - } - if rt.Changed("uuid") { - params["uuid"] = rt.Str("uuid") - } - return rt.CallMCP("send_ding_by_message", params) + []contract.ParamDecl{ + {Name: "group", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"}, + {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"}, + {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"}, }, + "dws ding +send-by-message --group --message-id --users ", +) + +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 ", + func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("recall_personal_ding", map[string]any{"openDingId": rt.Str("id")}) + }, +) + +func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { + declaration := dingContract(command, description, intent, true, nil, nil, params, example) + declaration.Interface.Reason = dingCompatibilityWriteReason + return shortcut.Shortcut{ + Service: "ding", Command: command, Product: "im", + Description: description, Intent: intent, Risk: risk, + Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, + Contract: declaration, + Flags: flags, Tips: []string{example}, Execute: execute, + } } -var RecallPersonal = shortcut.Shortcut{ - Service: "ding", Command: "+recall-personal", Product: "im", - Description: "撤回本人发起的 DING", Intent: "当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。", Risk: shortcut.RiskHighWrite, - Safety: contract.SafetySpec{ - Effect: "destructive", Risk: "high", - Confirmation: "user_required", Idempotency: "unknown", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_recall_personal", - CanonicalPath: "ding.shortcut_recall_personal", - CLIPath: "ding +recall-personal", - PrimaryCLIPath: "ding +recall-personal", +func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { + return shortcut.Shortcut{ + Service: "ding", Command: command, Product: "im", + Description: description, Intent: intent, Risk: risk, + Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract(command, description, intent, false, nil, nil, params, example), + Flags: flags, Tips: []string{example}, + Execute: func(*shortcut.RuntimeContext) error { + return dingUnavailable("ding/" + command) }, - Description: "撤回本人发起的 DING", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "撤回本人发起的 DING", - UseWhen: []string{"当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +recall-personal --id "}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("recall_personal_ding", map[string]any{ - "openDingId": rt.Str("id"), - }) - }, + } +} + +func dingPersonalRemindType(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "app": + return "APP", nil + case "sms": + return "SMS", nil + case "call": + return "PHONE", nil + default: + return "", apperrors.NewValidation("--type 必须是 app、sms 或 call") + } } func init() { - shortcut.Register( - List, - ReceiverStatus, - SendPersonal, - SendByMessage, - RecallPersonal, - ) + shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal) } diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..f5340cf3 --- /dev/null +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -0,0 +1,434 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package ding + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type dingCoverageCaller struct { + responses map[string][]string + failures map[string]error + history []string + arguments []map[string]any +} + +func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + if err := caller.failures[tool]; err != nil { + return nil, err + } + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing DING fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*dingCoverageCaller) Format() string { return "json" } +func (*dingCoverageCaller) DryRun() bool { return false } +func (*dingCoverageCaller) Fields() string { return "" } +func (*dingCoverageCaller) JQ() string { return "" } + +func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func runDingCoverageOutput(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) ([]byte, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.Bytes(), err +} + +func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} { + if declaration.Contract.Empty() { + t.Errorf("%s lacks Contract", declaration.Command) + } + if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" { + t.Errorf("%s lacks Safety", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + } + for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus} { + if declaration.Contract.Result == nil { + t.Errorf("%s lacks Result", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s is not interface-available", declaration.Command) + } + } + for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} { + if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s compatibility write safety/interface drift", declaration.Command) + } + } + if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s unavailable write safety/interface drift", SendByMessage.Command) + } + if List.Contract.Pagination == nil { + t.Fatal("+list lacks cursor pagination") + } +} + +func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) { + valid := map[string]any{ + "success": true, + "result": map[string]any{ + "dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": "fixture"}}, + "hasMore": true, + "nextCursor": float64(2), + }, + } + messages, page, err := dingProjectMessages(valid, "im/list_ding_messages") + if err != nil || len(messages) != 1 || !page.HasMore || page.Next != "2" || messages[0]["content"] != "fixture" { + t.Fatalf("valid list projection messages=%#v page=%+v err=%v", messages, page, err) + } + empty := map[string]any{"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": nil}} + if messages, page, err := dingProjectMessages(empty, "im/list_ding_messages"); err != nil || len(messages) != 0 || page.HasMore { + t.Fatalf("explicit terminal empty page messages=%#v page=%+v err=%v", messages, page, err) + } + fixtures := map[string]map[string]any{ + "empty": {}, + "missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "false success": {"success": false, "errorMsg": "fixture failure", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "missing result": {"success": true}, + "missing collection": {"success": true, "result": map[string]any{"hasMore": false}}, + "wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}}, + "bad item": {"success": true, "result": map[string]any{"dingMessages": []any{"bad"}, "hasMore": false}}, + "missing item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"dingContent": "fixture"}}, "hasMore": false}}, + "duplicate item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "same"}, map[string]any{"openDingId": "same"}}, "hasMore": false}}, + "missing pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}}}, + "wrong pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": "false"}}, + "empty continuation": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": true, "nextCursor": float64(2)}}, + "missing next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true}}, + "wrong next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true, "nextCursor": "2"}}, + "conflicting terminal": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": float64(2)}}, + "wrong optional content": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": float64(1)}}, "hasMore": false}}, + } + for name, fixture := range fixtures { + if projected, _, projectErr := dingProjectMessages(fixture, "im/list_ding_messages"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if _, _, err := dingProjectMessages(map[string]any{"success": false, "errorMsg": "fixture failure"}, "im/list_ding_messages"); err == nil || !strings.Contains(err.Error(), "fixture failure") { + t.Fatalf("remote failure message was not preserved: %v", err) + } +} + +func TestCrossPlatformCoverageDINGIntegerRepresentations(t *testing.T) { + for name, test := range map[string]struct { + value any + want int64 + }{ + "float64": {value: float64(1), want: 1}, + "int": {value: int(2), want: 2}, + "int64": {value: int64(3), want: 3}, + "json number": {value: json.Number("4"), want: 4}, + } { + t.Run(name, func(t *testing.T) { + if got, ok := dingInteger(test.value); !ok || got != test.want { + t.Fatalf("dingInteger(%T(%v))=(%d,%t), want (%d,true)", test.value, test.value, got, ok, test.want) + } + }) + } + for name, value := range map[string]any{ + "nan": math.NaN(), + "fraction": 1.5, + "overflow": math.MaxFloat64, + "bad json number": json.Number("not-an-integer"), + "wrong type": "1", + } { + t.Run(name, func(t *testing.T) { + if got, ok := dingInteger(value); ok { + t.Fatalf("dingInteger(%T(%v))=(%d,true), want invalid", value, value, got) + } + }) + } +} + +func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}} + items, err := dingProjectReceivers(valid, "im/list_ding_receiver_status", "ding-1") + if err != nil || len(items) != 1 || items[0]["confirmedStatus"] != int64(1) { + t.Fatalf("valid receiver projection=%#v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "empty": {}, + "missing collection": {"success": true, "result": map[string]any{}}, + "empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}}, + "bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}}, + "missing item id": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, + "identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, + "wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}}, + "missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}}, + } { + if projected, projectErr := dingProjectReceivers(fixture, "im/list_ding_receiver_status", "ding-1"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageDINGListExecutionFailuresPaginationAndLegacy(t *testing.T) { + t.Run("negative cursor validates before call", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{}} + if _, err := runDingCoverage(t, List, caller, "--cursor", "-1"); err == nil || len(caller.history) != 0 { + t.Fatalf("negative cursor error=%v calls=%v", err, caller.history) + } + }) + + t.Run("transport failure", func(t *testing.T) { + caller := &dingCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"list_ding_messages": errors.New("fixture transport failure")}, + } + if _, err := runDingCoverage(t, List, caller); err == nil || strings.Join(caller.history, ",") != "list_ding_messages" { + t.Fatalf("transport error=%v calls=%v", err, caller.history) + } + }) + + t.Run("projection failure", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[],"hasMore":true,"nextCursor":2}}`}, + }} + if _, err := runDingCoverage(t, List, caller); err == nil { + t.Fatal("malformed empty continuation returned success") + } + }) + + t.Run("stalled cursor", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":2}}`}, + }} + if _, err := runDingCoverage(t, List, caller, "--cursor", "2"); err == nil || !strings.Contains(err.Error(), "nextCursor") { + t.Fatalf("stalled cursor error=%v", err) + } + }) + + t.Run("advancing cursor legacy output", func(t *testing.T) { + legacy := List + legacy.OutputRollout = output.RolloutLegacyOnly + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":3}}`}, + }} + stdout, err := runDingCoverageOutput(t, legacy, caller, "--cursor", "2") + if err != nil { + t.Fatalf("legacy advancing page: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout, &payload); err != nil || payload["nextCursor"] != "3" || payload["complete"] != false { + t.Fatalf("legacy payload=%#v decode=%v", payload, err) + } + if len(caller.arguments) != 1 || caller.arguments[0]["cursor"] != 2 { + t.Fatalf("legacy cursor arguments=%#v", caller.arguments) + } + }) + + t.Run("invalid unified page evidence", func(t *testing.T) { + cmd := corecmd.New(shortcut.FromShortcut(List)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + rt := shortcut.RuntimeContextForTest(cmd, List) + if err := outputDingPage(rt, nil, dingPageEvidence{HasMore: true}); err == nil { + t.Fatal("continuation without next cursor returned success") + } + }) +} + +func TestCrossPlatformCoverageDINGReceiverExecutionFailuresAndLegacy(t *testing.T) { + t.Run("transport failure", func(t *testing.T) { + caller := &dingCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"list_ding_receiver_status": errors.New("fixture transport failure")}, + } + if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil { + t.Fatal("receiver transport failure returned success") + } + }) + + t.Run("projection failure", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[]}}`}, + }} + if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil { + t.Fatal("empty receiver response returned success") + } + }) + + t.Run("legacy output", func(t *testing.T) { + legacy := ReceiverStatus + legacy.OutputRollout = output.RolloutLegacyOnly + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`}, + }} + stdout, err := runDingCoverageOutput(t, legacy, caller, "--ding-id", "ding-1") + if err != nil { + t.Fatalf("receiver legacy output: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout, &payload); err != nil || payload["count"] != float64(1) { + t.Fatalf("receiver legacy payload=%#v decode=%v", payload, err) + } + if len(caller.arguments) != 1 || caller.arguments[0]["openDingId"] != "ding-1" { + t.Fatalf("receiver arguments=%#v", caller.arguments) + } + }) +} + +func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) { + listCaller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`}, + }} + cmd, err := runDingCoverage(t, List, listCaller, "--type", "ALL") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 { + t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data := envelope["data"].(map[string]any) + if _, leaked := data["result"]; leaked || data["count"] != float64(1) || data["complete"] != true { + t.Fatalf("list unified projection=%#v", data) + } + if strings.Join(listCaller.history, ",") != "list_ding_messages" { + t.Fatalf("list call history=%v", listCaller.history) + } + + receiverCaller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`}, + }} + if _, err := runDingCoverage(t, ReceiverStatus, receiverCaller, "--ding-id", "ding-1"); err != nil { + t.Fatal(err) + } + if strings.Join(receiverCaller.history, ",") != "list_ding_receiver_status" { + t.Fatalf("receiver call history=%v", receiverCaller.history) + } +} + +func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) { + args := []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"} + unconfirmed := &dingCoverageCaller{responses: map[string][]string{}} + err := runDingRoot(t, SendByMessage, unconfirmed, false, args...) + if err == nil || len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history) + } + confirmed := &dingCoverageCaller{responses: map[string][]string{}} + err = runDingRoot(t, SendByMessage, confirmed, true, args...) + if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 { + t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history) + } +} + +func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) { + for input, want := range map[string]string{"app": "APP", " sms ": "SMS", "call": "PHONE"} { + got, err := dingPersonalRemindType(input) + if err != nil || got != want { + t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want (%q,nil)", input, got, err, want) + } + } + + sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"} + unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 { + t.Fatalf("unconfirmed send error=%v calls=%v", err, unconfirmedSend.history) + } + confirmedSend := &dingCoverageCaller{responses: map[string][]string{ + "send_personal_ding": {`{"success":true,"result":{"openDingId":"ding-fixture"}}`}, + }} + if err := runDingRoot(t, SendPersonal, confirmedSend, true, sendArgs...); err != nil { + t.Fatalf("confirmed send failed: %v", err) + } + if strings.Join(confirmedSend.history, ",") != "send_personal_ding" || len(confirmedSend.arguments) != 1 { + t.Fatalf("confirmed send calls=%v args=%v", confirmedSend.history, confirmedSend.arguments) + } + sendParams := confirmedSend.arguments[0] + users, _ := sendParams["receiverOpenDingTalkIds"].([]string) + if strings.Join(users, ",") != "D-fixture" || sendParams["content"] != "fixture" || sendParams["remindType"] != "PHONE" || sendParams["uuid"] != "fixture-uuid" { + t.Fatalf("confirmed send params=%#v", sendParams) + } + + invalidType := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, SendPersonal, invalidType, true, "--users", "D-fixture", "--content", "fixture", "--type", "other"); err == nil || len(invalidType.history) != 0 { + t.Fatalf("invalid type error=%v calls=%v", err, invalidType.history) + } + + recallArgs := []string{"--id", "ding-fixture"} + unconfirmedRecall := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, RecallPersonal, unconfirmedRecall, false, recallArgs...); err == nil || len(unconfirmedRecall.history) != 0 { + t.Fatalf("unconfirmed recall error=%v calls=%v", err, unconfirmedRecall.history) + } + confirmedRecall := &dingCoverageCaller{responses: map[string][]string{ + "recall_personal_ding": {`{"success":true,"result":true}`}, + }} + if err := runDingRoot(t, RecallPersonal, confirmedRecall, true, recallArgs...); err != nil { + t.Fatalf("confirmed recall failed: %v", err) + } + if strings.Join(confirmedRecall.history, ",") != "recall_personal_ding" || confirmedRecall.arguments[0]["openDingId"] != "ding-fixture" { + t.Fatalf("confirmed recall calls=%v args=%v", confirmedRecall.history, confirmedRecall.arguments) + } +} + +func runDingRoot(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, yes bool, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "ding"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + argv := []string{"ding", declaration.Command} + argv = append(argv, args...) + if yes { + argv = append(argv, "--yes") + } + root.SetArgs(argv) + return root.Execute() +} diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go new file mode 100644 index 00000000..6e09854c --- /dev/null +++ b/internal/shortcut/oa/common.go @@ -0,0 +1,415 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const oaCompositeReason = "Reviewed OA Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable identity checks, truthful pagination, output projection, and confirmation; no single MCP interface represents the complete command contract." + +type oaPageEvidence struct { + Known bool + HasMore bool + Next string +} + +func oaCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效审批记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格验证后的 OA 审批业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`, + description, collection, description, collection, + )), + SensitivePaths: []string{collection + ".title", collection + ".originatorName", collection + ".formValueVOS", collection + ".userId"}, + } +} + +func oaObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格验证且身份匹配的 OA 审批对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.title", "value.processInstanceTitle", "value.formValueVOS", "value.originatorUserid", "value.operationRecords", "value.userId"}, + } +} + +func oaWriteResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"processInstanceId":{"type":"string","description":"被处理审批实例的稳定 ID"},"taskId":{"type":"string","description":"被处理审批任务的稳定 ID"},"verified":{"type":"boolean","description":"写后精确任务读回是否证明目标任务不再待处理"}},"required":["processInstanceId","taskId","verified"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"processInstanceId", "taskId"}, + } +} + +func oaReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func oaWriteSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} +} + +func oaContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "oa " + command + availability := contract.InterfaceUnavailable + interfaceReason := strings.TrimSpace(intent) + if available { + availability = contract.InterfaceAvailable + interfaceReason = oaCompositeReason + } + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", Name: name, CanonicalPath: "oa." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: interfaceReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"只需原始 OA MCP 响应时使用 oa approval 原子命令;缺少稳定实例、任务或表单身份时不要猜测"}, + Examples: examples, + }, + } +} + +func oaPagePagination(parameter string) *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: parameter, + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func oaResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func oaPostWriteError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("write_verification"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +// oaRequireSuccess accepts only the two success encodings observed from the +// OA backend. Missing, false, null, and all other encodings fail closed. +func oaRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return oaResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 OA 调用成功或结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return oaResponseError(operation, "missing_success", "OA 响应缺少 success 业务状态") + } + message := oaFirstString(data, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "OA 服务未明确返回成功状态" + } + return oaResponseError(operation, "remote_failure", message) +} + +func oaLookup(object map[string]any, path string) (any, bool) { + var current any = object + for _, segment := range strings.Split(path, ".") { + mapped, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = mapped[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func oaRequireObject(data map[string]any, operation, path string) (map[string]any, error) { + if err := oaRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := oaLookup(data, path) + if !present || value == nil { + return nil, oaResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, oaResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value)) + } + return object, nil +} + +func oaRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := oaRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := oaLookup(data, path) + if !present { + return nil, oaResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, oaResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func oaFirstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func oaScalarString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func oaIdentity(object map[string]any, keys ...string) string { + for _, key := range keys { + if value := oaScalarString(object[key]); value != "" { + return value + } + } + return "" +} + +func oaRequireIdentity(object map[string]any, operation, expected string, keys ...string) error { + actual := oaIdentity(object, keys...) + if actual == "" { + return oaResponseError(operation, "missing_stable_id", "OA 业务对象缺少稳定 ID") + } + if expected != "" && actual != expected { + return oaResponseError(operation, "identity_mismatch", "OA 业务对象 ID 与请求目标不一致") + } + return nil +} + +func oaProjectForms(data map[string]any, operation, path string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + forms := make([]map[string]any, 0, len(items)) + for index, item := range items { + code := oaIdentity(item, "processCode") + name := oaFirstString(item, "processName", "name") + if code == "" || name == "" { + return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("表单结果第 %d 项缺少 processCode 或名称", index)) + } + row := map[string]any{"processCode": code, "name": name} + if icon := oaFirstString(item, "processIconUrl", "iconUrl"); icon != "" { + row["iconUrl"] = icon + } + forms = append(forms, row) + } + return forms, nil +} + +func oaProjectInstances(data map[string]any, operation, path string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + instances := make([]map[string]any, 0, len(items)) + for index, item := range items { + id := oaIdentity(item, "processInstanceId") + if id == "" { + return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批结果第 %d 项缺少 processInstanceId", index)) + } + row := map[string]any{"processInstanceId": id} + if title := oaFirstString(item, "title", "processInstanceTitle"); title != "" { + row["title"] = title + } + if status := oaScalarString(item["status"]); status != "" { + row["status"] = status + } + if businessID := oaIdentity(item, "businessId"); businessID != "" { + row["businessId"] = businessID + } + if originator := oaFirstString(item, "originatorName", "originatorUserName"); originator != "" { + row["originatorName"] = originator + } + for _, key := range []string{"processCreateTime", "createTime"} { + if value, present := item[key]; present && value != nil { + row["createTime"] = value + break + } + } + instances = append(instances, row) + } + return instances, nil +} + +func oaProjectTasks(data map[string]any, operation string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, "result.taskIdList") + if err != nil { + return nil, err + } + tasks := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id := oaIdentity(item, "taskId") + if id == "" { + return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批任务第 %d 项缺少 taskId", index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, oaResponseError(operation, "duplicate_item_identity", "审批任务响应包含重复 taskId") + } + seen[id] = struct{}{} + tasks = append(tasks, map[string]any{"taskId": id}) + } + return tasks, nil +} + +func oaCursorPage(result map[string]any, operation string, current int) (oaPageEvidence, error) { + raw, present := result["hasMore"] + if !present { + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "游标响应缺少 hasMore;不能把当前页当作完整结果") + } + hasMore, ok := raw.(bool) + if !ok { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值") + } + page := oaPageEvidence{Known: true, HasMore: hasMore} + next := oaScalarString(result["nextCursor"]) + if !hasMore { + if next != "" { + return oaPageEvidence{}, oaResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空") + } + return page, nil + } + if next == "" { + return oaPageEvidence{}, oaResponseError(operation, "missing_next_cursor", "hasMore=true 但缺少 nextCursor") + } + nextValue, err := strconv.Atoi(next) + if err != nil { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "nextCursor 应为整数") + } + if nextValue <= current { + return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 没有严格前进") + } + page.Next = next + return page, nil +} + +func oaHasMorePage(result map[string]any, operation string, currentPage int) (oaPageEvidence, error) { + raw, present := result["hasMore"] + if !present { + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "审批列表缺少 hasMore,无法证明结果完整或提供续页凭据") + } + hasMore, ok := raw.(bool) + if !ok { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值") + } + page := oaPageEvidence{Known: true, HasMore: hasMore} + if hasMore { + if currentPage <= 0 { + return oaPageEvidence{}, oaResponseError(operation, "invalid_page", "当前页码无效,无法生成下一页凭据") + } + page.Next = strconv.Itoa(currentPage + 1) + } + return page, nil +} + +func outputOAPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, page oaPageEvidence) error { + if !page.Known { + return oaResponseError("oa/pagination", "missing_pagination", "响应缺少分页终态证据") + } + payload := map[string]any{"count": len(items), collection: items, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextPage"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return oaResponseError("oa/pagination", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(items)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func outputOACompleteCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any) error { + payload := map[string]any{"count": len(items), collection: items, "complete": true} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func validateOAPage(page, limit int) error { + if page <= 0 { + return apperrors.NewValidation("--page 必须大于 0") + } + if limit <= 0 || limit > 100 { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil +} diff --git a/internal/shortcut/oa/list_forms_projection_test.go b/internal/shortcut/oa/list_forms_projection_test.go deleted file mode 100644 index 21ce7df8..00000000 --- a/internal/shortcut/oa/list_forms_projection_test.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package oa - -import ( - "encoding/json" - "testing" -) - -// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss -// class: list_user_visible_process nests the forms under result.processCodeList. -// The resolver MUST probe that exact key — otherwise the whole list silently -// projects to empty (exit 0, no error envelope) while the backend has data. -func TestListFormsProjectProcessCodeListShape(t *testing.T) { - // Faithful list_user_visible_process shape (as returned by the backend). - const raw = `{"result":{"processCodeList":[ - {"processCode":"PROC-1","processName":"leave","dirName":"attendance"}, - {"processCode":"PROC-2","processName":"check-in fix","dirName":"attendance"}, - {"processCode":"PROC-3","processName":"overtime","dirName":"attendance"} - ],"totalCount":-1},"success":true}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - - forms := listFormsProject(data) - if len(forms) != 3 { - t.Fatalf("lower/upper mismatch: 3 forms in backend, projection returned %d (forms=%v)", len(forms), forms) - } - for _, f := range forms { - if f["processCode"] == nil || f["name"] == nil { - t.Fatalf("projected form missing processCode/name: %v", f) - } - } -} - -// TestListFormsProjectBareArrayShape ensures a bare top-level array still works. -func TestListFormsProjectBareArrayShape(t *testing.T) { - const raw = `{"result":[ - {"processCode":"PROC-9","processName":"generic approval"} - ]}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if forms := listFormsProject(data); len(forms) != 1 { - t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms) - } -} - -// TestOAInstanceResolveListValuesShape guards the shared resolver behind -// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval -// instance tools nest the list under result.values; the resolver must probe -// "values" or all four commands silently return empty despite backend records. -func TestOAInstanceResolveListValuesShape(t *testing.T) { - const raw = `{"result":{"hasMore":false,"values":[ - {"processInstanceId":"i-1","title":"user leave"}, - {"processInstanceId":"i-2","title":"user reimbursement"} - ]}}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if got := oaInstanceResolveList(data); len(got) != 2 { - t.Fatalf("lower/upper mismatch: result.values has 2 entries, resolver returned %d", len(got)) - } - // End-to-end through a real projection that uses the shared resolver. - if instances := listSubmittedProject(data); len(instances) != 2 { - t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances) - } -} diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 80d1590d..b5ab751c 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -1,59 +1,73 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package oa registers declarative shortcuts for the DingTalk OA approval -// service, wrapping the raw MCP tools exposed by the dws oa helper. +// Package oa registers strict declarative shortcuts for DingTalk OA approval. package oa import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "fmt" + "strconv" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -// ListPending — 查询待我处理的审批 (list_pending_approvals) +func parseOAStringPage(rt *shortcut.RuntimeContext, name string, fallback int) (int, error) { + raw := rt.Str(name) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是整数", name)) + } + return value, nil +} + +func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string]any, page int) error { + operation := "oa/" + tool + data, err := rt.CallMCPData("oa", tool, params) + if err != nil { + return err + } + instances, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, _ := data["result"].(map[string]any) + evidence, err := oaHasMorePage(result, operation, page) + if err != nil { + return err + } + return outputOAPage(rt, "instances", instances, evidence) +} + var ListPending = shortcut.Shortcut{ - Service: "oa", - Command: "+list-pending", - Product: "oa", - Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", - Intent: "当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_pending", - CanonicalPath: "oa.shortcut_list_pending", - CLIPath: "oa +list-pending", - PrimaryCLIPath: "oa +list-pending", + Service: "oa", Command: "+list-pending", Product: "oa", + Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", + Intent: "按 epoch 毫秒时间范围查询当前用户待处理审批;只有显式成功、严格实例数组与可续页证据齐全时才返回结果。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+list-pending", + "查询待我处理的审批(时间范围为 epoch 毫秒)", + "需要按时间范围读取待我审批的实例,并取得稳定 processInstanceId 时使用;没有安全非空待办 fixture 前不会进入公开发现。", + true, + oaCollectionResult("instances", "严格验证的待处理审批实例页"), + oaPagePagination("page"), + []contract.ParamDecl{ + {Name: "start", Property: "start"}, + {Name: "end", Property: "end"}, + {Name: "page", Property: "page"}, + {Name: "limit", Property: "limit"}, + {Name: "query", Property: "query"}, }, - Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "查询待我处理的审批(时间范围为 epoch 毫秒)", - UseWhen: []string{"当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销"}, - }, - }, + "dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20", + ), Flags: []shortcut.Flag{ {Name: "start", Type: shortcut.FlagInt, Desc: "开始时间(epoch 毫秒)", Required: true}, {Name: "end", Type: shortcut.FlagInt, Desc: "结束时间(epoch 毫秒)", Required: true}, @@ -61,12 +75,25 @@ var ListPending = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "starTime": rt.Int("start"), - "endTime": rt.Int("end"), + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end", "page", "limit"}, Description: "--start/--end 必须是递增的正整数 epoch 毫秒;--page 必须大于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("start") <= 0 || rt.Int("end") <= rt.Int("start") { + return apperrors.NewValidation("--start/--end 必须是递增的正整数 epoch 毫秒范围") } + page, err := parseOAStringPage(rt, "page", 1) + if err != nil { + return err + } + limit, err := parseOAStringPage(rt, "limit", 20) + if err != nil { + return err + } + return validateOAPage(page, limit) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + page, _ := parseOAStringPage(rt, "page", 1) + params := map[string]any{"starTime": rt.Int("start"), "endTime": rt.Int("end")} if rt.Changed("page") { params["pageNum"] = rt.Str("page") } @@ -76,530 +103,154 @@ var ListPending = shortcut.Shortcut{ if rt.Changed("query") { params["query"] = rt.Str("query") } - data, err := rt.CallMCPData("oa", "list_pending_approvals", params) - if err != nil { - return err - } - instances := listPendingProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + return oaInstancePage(rt, "list_pending_approvals", params, page) }, } -// listPendingProject reshapes the raw list_pending_approvals response into the -// same clean {processInstanceId, title, status, createTime} approval list as -// +list-executed/+list-submitted, so all approval-instance listings project -// identically. It reuses the shared oaInstance* defensive probes, tolerating -// response-shape and key-spelling drift. -func listPendingProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// Detail — 获取审批实例详情 (get_processInstance_detail) -// Approve — 同意审批 (approve_processInstance) -// Reject — 拒绝审批 (reject_processInstance) -// Revoke — 撤销已发起的审批 (revoke_processInstance) -// Records — 获取审批操作记录 (get_processInstance_records) -// ListInitiated — 查询审批模板下已发起的审批记录 (list_initiated_instances) -// ListTasks — 查询待我审批的任务 ID (list_pending_tasks) -// ListForms — 获取当前用户可见的审批表单列表 (list_user_visible_process) var ListForms = shortcut.Shortcut{ - Service: "oa", - Command: "+list-forms", - Product: "oa", - Description: "获取当前用户可见的审批表单列表", - Intent: "当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_forms", - CanonicalPath: "oa.shortcut_list_forms", - CLIPath: "oa +list-forms", - PrimaryCLIPath: "oa +list-forms", - }, - Description: "获取当前用户可见的审批表单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取当前用户可见的审批表单列表", - UseWhen: []string{"当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-forms --cursor 0 --limit 100"}, - }, - }, + Service: "oa", Command: "+list-forms", Product: "oa", + Description: "获取当前用户可见的审批表单列表", + Intent: "按服务端游标读取当前用户可发起的审批表单;缺少 hasMore/nextCursor 或游标不前进时失败,不把重复首页宣称为完整列表。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+list-forms", "获取当前用户可见的审批表单列表", + "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故不进入 Agent 公开发现。", + true, + oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"), + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}}, + "dws oa +list-forms --cursor 0 --limit 100", + ), Flags: []shortcut.Flag{ {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"}, }, - Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor", "limit"}, Description: "--cursor 不能小于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("cursor") < 0 { + return apperrors.NewValidation("--cursor 不能小于 0") + } + if rt.Int("limit") <= 0 || rt.Int("limit") > 100 { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{ - "cursor": rt.Int("cursor"), - "pageSize": rt.Int("limit"), - }) + const operation = "oa/list_user_visible_process" + data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{"cursor": rt.Int("cursor"), "pageSize": rt.Int("limit")}) if err != nil { return err } - forms := listFormsProject(data) - return rt.Output(map[string]any{"count": len(forms), "forms": forms}) + forms, err := oaProjectForms(data, operation, "result.processCodeList") + if err != nil { + return err + } + result, _ := data["result"].(map[string]any) + page, err := oaCursorPage(result, operation, rt.Int("cursor")) + if err != nil { + return err + } + return outputOAPage(rt, "forms", forms, page) }, } -// listFormsProject reshapes the raw list_user_visible_process response into a -// clean approval-form list ({processCode, name, iconUrl}) — the output-projection -// fidelity the framework applies to every list command. The list container and -// per-item field names are probed defensively across candidate keys so the -// projection tolerates response-shape drift rather than crashing or fabricating. -func listFormsProject(data map[string]any) []map[string]any { - raw := oaFormResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaFormProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// oaFormResolveList locates the form list inside a response, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func oaFormResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // list_user_visible_process nests the forms under result.processCodeList; - // probing must include that exact key (both at the top level in case the - // envelope is already unwrapped, and one level deeper under result/data) or - // the whole list silently projects to empty. - for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// oaFormProjectItem picks the stable identity/label fields of a single approval -// form, probing candidate key spellings so it survives snake/camel drift. -func oaFormProjectItem(m map[string]any) map[string]any { - row := map[string]any{} - if v, ok := oaFormFirst(m, "processCode", "process_code", "code"); ok { - row["processCode"] = v - } - if v, ok := oaFormFirst(m, "name", "processName", "process_name", "flowTitle"); ok { - row["name"] = v - } - if v, ok := oaFormFirst(m, "iconUrl", "icon_url", "iconName"); ok { - row["iconUrl"] = v - } - return row -} - -// oaFormFirst returns the first present candidate key's value. -func oaFormFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - -// SearchForms — 按关键字模糊搜索可见审批表单 (search_form) var SearchForms = shortcut.Shortcut{ - Service: "oa", - Command: "+search-forms", - Product: "oa", - Description: "按关键字模糊搜索当前用户可见的审批表单", - Intent: "当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", + Service: "oa", Command: "+search-forms", Product: "oa", + Description: "按关键字模糊搜索当前用户可见的审批表单", + Intent: "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+search-forms", "按关键字模糊搜索当前用户可见的审批表单", + "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + true, + oaCollectionResult("forms", "严格验证的审批表单搜索结果"), nil, + []contract.ParamDecl{{Name: "query", Property: "query"}}, + "dws oa +search-forms --query 报销", + ), + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称);去除空白后不能为空", Required: true}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}}, + Tips: []string{`dws oa +search-forms --query 报销`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return apperrors.NewValidation("--query 不能为空") + } + return nil }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_search_forms", - CanonicalPath: "oa.shortcut_search_forms", - CLIPath: "oa +search-forms", - PrimaryCLIPath: "oa +search-forms", - }, - Description: "按关键字模糊搜索当前用户可见的审批表单", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "按关键字模糊搜索当前用户可见的审批表单", - UseWhen: []string{"当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +search-forms --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}, - }, - Tips: []string{`dws oa +search-forms --query 报销`}, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("oa", "search_form", map[string]any{ - "query": rt.Str("query"), - }) + const operation = "oa/search_form" + data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": strings.TrimSpace(rt.Str("query"))}) if err != nil { return err } - forms := searchFormsProject(data) - return rt.Output(map[string]any{"count": len(forms), "forms": forms}) - }, -} - -// searchFormsProject reshapes the raw search_form response into the same clean -// {processCode, name, iconUrl} list as +list-forms, so both approval-form -// listings project identically. It reuses the shared oaForm* defensive probes, -// tolerating response-shape and key-spelling drift. -func searchFormsProject(data map[string]any) []map[string]any { - raw := oaFormResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaFormProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// DingInfo — 获取审批任务的被催办人 userId (oa_ding_user) -// ListExecuted — 获取当前用户已处理过的审批单列表 (get_done_tasks) -var ListExecuted = shortcut.Shortcut{ - Service: "oa", - Command: "+list-executed", - Product: "oa", - Description: "获取当前用户已经处理过的审批单列表", - Intent: "当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_executed", - CanonicalPath: "oa.shortcut_list_executed", - CLIPath: "oa +list-executed", - PrimaryCLIPath: "oa +list-executed", - }, - Description: "获取当前用户已经处理过的审批单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取当前用户已经处理过的审批单列表", - UseWhen: []string{"当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-executed --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-executed --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_done_tasks", params) + forms, err := oaProjectForms(data, operation, "result") if err != nil { return err } - instances := listExecutedProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + return outputOACompleteCollection(rt, "forms", forms) }, } -// listExecutedProject reshapes the raw get_done_tasks response into a clean -// approval-instance list ({processInstanceId, title, status, createTime}) — the -// the clean output projection applied to every list command. The -// list container and per-item field names are probed defensively across -// candidate keys so the projection tolerates response-shape drift rather than -// crashing or fabricating data. -func listExecutedProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// oaInstanceResolveList locates the approval-instance list inside a response, -// tolerating a bare top-level array or nesting one level under a common -// envelope key. -func oaInstanceResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // The approval instance tools (list_pending_approvals, get_done_tasks, - // get_submitted_instances, get_noticed_instances) all nest the instance list - // under result.values; "values" MUST be in the probe set or every one of - // +list-pending / +list-executed / +list-submitted / +list-cc silently - // projects to empty despite the backend returning records. - for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// oaInstanceProjectItem picks the stable identity/label fields of a single -// approval instance, probing candidate key spellings so it survives -// snake/camel drift. -func oaInstanceProjectItem(m map[string]any) map[string]any { - row := map[string]any{} - if v, ok := oaFormFirst(m, "processInstanceId", "process_instance_id", "instanceId", "id"); ok { - row["processInstanceId"] = v - } - if v, ok := oaFormFirst(m, "title", "processTitle", "process_title", "name"); ok { - row["title"] = v - } - if v, ok := oaFormFirst(m, "status", "result", "processResult"); ok { - row["status"] = v - } - if v, ok := oaFormFirst(m, "createTime", "create_time", "gmtCreate", "createdTime"); ok { - row["createTime"] = v - } - return row -} - -// ListSubmitted — 获取当前用户已发起的审批单列表 (get_submitted_instances) -var ListSubmitted = shortcut.Shortcut{ - Service: "oa", - Command: "+list-submitted", - Product: "oa", - Description: "获取当前用户已发起的审批单列表", - Intent: "当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_submitted", - CanonicalPath: "oa.shortcut_list_submitted", - CLIPath: "oa +list-submitted", - PrimaryCLIPath: "oa +list-submitted", +func oaNumberedInstanceShortcut(command, tool, description, intent string) shortcut.Shortcut { + declaration := shortcut.Shortcut{ + Service: "oa", Command: command, Product: "oa", + Description: description, Intent: intent, Risk: shortcut.RiskRead, + Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract(command, description, intent, true, + oaCollectionResult("instances", description), oaPagePagination("page"), + []contract.ParamDecl{{Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}}, + "dws oa "+command+" --page 1 --limit 20"), + Flags: []shortcut.Flag{ + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码;--page 必须大于 0"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小;--limit 必须在 1-100"}, + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Description: "获取当前用户已发起的审批单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取当前用户已发起的审批单列表", - UseWhen: []string{"当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-submitted --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-submitted --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, + } + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + page, err := parseOAStringPage(rt, "page", 1) if err != nil { return err } - instances := listSubmittedProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) - }, -} - -// listSubmittedProject reshapes the raw get_submitted_instances response into -// the same clean {processInstanceId, title, status, createTime} approval list -// as +list-executed, so both instance listings project identically. It reuses -// the shared oaInstance* defensive probes, tolerating response-shape and -// key-spelling drift. -func listSubmittedProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// ListCc — 获取抄送当前用户的审批单列表 (get_noticed_instances) -var ListCc = shortcut.Shortcut{ - Service: "oa", - Command: "+list-cc", - Product: "oa", - Description: "获取抄送当前用户的审批单列表", - Intent: "当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_cc", - CanonicalPath: "oa.shortcut_list_cc", - CLIPath: "oa +list-cc", - PrimaryCLIPath: "oa +list-cc", - }, - Description: "获取抄送当前用户的审批单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取抄送当前用户的审批单列表", - UseWhen: []string{"当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-cc --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-cc --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_noticed_instances", params) + limit, err := parseOAStringPage(rt, "limit", 20) if err != nil { return err } - instances := listCcProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) - }, -} - -// listCcProject reshapes the raw get_noticed_instances response into the same -// clean {processInstanceId, title, status, createTime} approval list as the -// other instance listings, so all approval-instance listings project -// identically. It reuses the shared oaInstance* defensive probes, tolerating -// response-shape and key-spelling drift. -func listCcProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } + return validateOAPage(page, limit) } - return out + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + page, _ := parseOAStringPage(rt, "page", 1) + params := map[string]any{"pageNumber": rt.Str("page"), "pageSize": rt.Str("limit")} + if query := strings.TrimSpace(rt.Str("query")); query != "" { + params["query"] = query + } + return oaInstancePage(rt, tool, params, page) + } + return declaration } -// RedirectTask — 转交审批任务给其他人 (redirect_task) -// Comment — 对审批实例添加评论 (dingflow_comments) -// CcNotice — 对审批实例进行抄送 (oa_cc_noticer) -// AppendTask — 对审批任务进行加签 (append_task) -// RevertActivities — 获取审批任务可回退的节点信息 (get_inst_revert_activities) -// RevertTask — 退回审批任务到指定节点 (revert_task) +var ListExecuted = oaNumberedInstanceShortcut( + "+list-executed", "get_done_tasks", "获取当前用户已经处理过的审批单列表", + "需要回顾当前用户已同意或拒绝过的审批实例时使用;与待办、已发起和抄送列表分开。", +) + +var ListSubmitted = oaNumberedInstanceShortcut( + "+list-submitted", "get_submitted_instances", "获取当前用户已发起的审批单列表", + "需要查看当前用户发起的审批实例和当前状态时使用;返回稳定 processInstanceId 与可续页证据。", +) + +var ListCc = oaNumberedInstanceShortcut( + "+list-cc", "get_noticed_instances", "获取抄送当前用户的审批单列表", + "需要查看抄送给当前用户的审批实例时使用;没有安全非空抄送 fixture 前不会进入公开发现。", +) + func init() { - shortcut.Register( - ListPending, - ListForms, - SearchForms, - ListExecuted, - ListSubmitted, - ListCc, - ) + shortcut.Register(ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, ListCc) } diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..7df9f056 --- /dev/null +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -0,0 +1,728 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type oaCoverageCaller struct { + responses map[string][]string + history []string + arguments []map[string]any +} + +func (caller *oaCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing OA fake response for " + tool) + } + caller.responses[tool] = queue[1:] + if queue[0] == "__ERROR__" { + return nil, errors.New("injected OA failure") + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*oaCoverageCaller) Format() string { return "json" } +func (*oaCoverageCaller) DryRun() bool { return false } +func (*oaCoverageCaller) Fields() string { return "" } +func (*oaCoverageCaller) JQ() string { return "" } + +func runOACoverage(t *testing.T, declaration shortcut.Shortcut, caller *oaCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func runOAConfirmedCoverage(t *testing.T, caller *oaCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "oa"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + root.AddCommand(service) + root.SetArgs(append([]string{"oa", "+approve-by"}, args...)) + return root.Execute() +} + +func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{ + ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, + ListCc, PendingApprovals, DoneApprovals, Approve, MyInitiated, + } { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks Contract.Result", declaration.Command) + } + if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" { + t.Errorf("%s lacks Safety", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + } + if Approve.Safety.Confirmation != "user_required" || Approve.Risk != shortcut.RiskHighWrite { + t.Fatal("+approve-by must require explicit high-risk confirmation") + } + unavailable := oaContract("+fixture-unavailable", "fixture unavailable", "reviewed fixture is unavailable", false, oaCollectionResult("items", "fixture items"), nil, nil) + if unavailable.Interface == nil || unavailable.Interface.Availability != "unavailable" || unavailable.Interface.Reason != "reviewed fixture is unavailable" { + t.Fatalf("unavailable OA contract interface=%+v", unavailable.Interface) + } + for _, declaration := range []shortcut.Shortcut{ListPending, ListForms, ListExecuted, ListSubmitted, ListCc, MyInitiated} { + if declaration.Contract.Pagination == nil { + t.Errorf("%s lacks Pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageOAStrictResponseMatrix(t *testing.T) { + validEmpty := map[string]any{"success": "true", "result": map[string]any{"values": []any{}}} + items, err := oaProjectInstances(validEmpty, "oa/test", "result.values") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty must succeed: items=%v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "empty": {}, + "missing success": {"result": map[string]any{"values": []any{}}}, + "false success": {"success": false, "result": map[string]any{"values": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{}}, + "wrong collection": {"success": true, "result": map[string]any{"values": map[string]any{}}}, + "bad item": {"success": true, "result": map[string]any{"values": []any{"bad"}}}, + "missing identity": {"success": true, "result": map[string]any{"values": []any{map[string]any{"title": "fixture"}}}}, + } { + if projected, err := oaProjectInstances(fixture, "oa/test", "result.values"); err == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + forms := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}} + if projected, err := oaProjectForms(forms, "oa/forms", "result.processCodeList"); err != nil || len(projected) != 1 { + t.Fatalf("strict forms projection=%#v err=%v", projected, err) + } + badForm := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processName": "fixture"}}}} + if projected, err := oaProjectForms(badForm, "oa/forms", "result.processCodeList"); err == nil { + t.Fatalf("form without processCode returned success: %#v", projected) + } + search := map[string]any{"success": true, "result": []any{map[string]any{"processCode": "p", "processName": "fixture"}}} + if projected, err := oaProjectForms(search, "oa/search", "result"); err != nil || len(projected) != 1 { + t.Fatalf("strict search projection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { + if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1); err == nil { + t.Fatal("numbered page without hasMore was accepted") + } + if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 1); err == nil { + t.Fatal("wrong hasMore type was accepted") + } + page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 2) + if err != nil || !page.HasMore || page.Next != "3" { + t.Fatalf("numbered continuation=%+v err=%v", page, err) + } + if _, err := oaCursorPage(map[string]any{}, "oa/cursor", 0); err == nil { + t.Fatal("cursor response without pagination was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true}, "oa/cursor", 0); err == nil { + t.Fatal("hasMore without nextCursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 1); err == nil { + t.Fatal("stalled cursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 2); err == nil { + t.Fatal("backward cursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": "not-a-number"}, "oa/cursor", 1); err == nil { + t.Fatal("non-integer cursor was accepted") + } + page, err = oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(2)}, "oa/cursor", 1) + if err != nil || page.Next != "2" { + t.Fatalf("cursor continuation=%+v err=%v", page, err) + } +} + +func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{ + "get_submitted_instances": {`{"success":"true","result":{"values":[],"hasMore":false}}`}, + }} + cmd, err := runOACoverage(t, MyInitiated, caller, "--page", "1", "--limit", "20") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + code, emitted, err := output.EmitStoredResult(cmd) + if err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if _, raw := envelope.Data["result"]; raw { + t.Fatalf("raw result leaked: %s", stdout.String()) + } + initiated, ok := envelope.Data["initiated"].([]any) + if !ok || len(initiated) != 0 || envelope.Data["complete"] != true { + t.Fatalf("strict empty initiated payload=%#v", envelope.Data) + } +} + +func TestCrossPlatformCoverageOAApproveConfirmationAndReadback(t *testing.T) { + unconfirmed := &oaCoverageCaller{responses: map[string][]string{}} + helpers.InitDepsForTest(t, unconfirmed) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "oa"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + root.AddCommand(service) + root.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture"}) + if err := root.Execute(); err == nil { + t.Fatal("unconfirmed approval unexpectedly succeeded") + } + if len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed approval made calls: %v", unconfirmed.history) + } + + confirmed := &oaCoverageCaller{responses: map[string][]string{ + "list_pending_approvals": {`{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"instance-1","title":"fixture"}]}}`}, + "list_pending_tasks": { + `{"success":true,"result":{"taskIdList":[{"taskId":1}]}}`, + `{"success":true,"result":{"taskIdList":[]}}`, + }, + "approve_processInstance": {`{"success":true,"result":{"accepted":true}}`}, + }} + helpers.InitDepsForTest(t, confirmed) + confirmedRoot := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + confirmedRoot.PersistentFlags().Bool("yes", false, "") + confirmedRoot.PersistentFlags().Bool("dry-run", false, "") + confirmedRoot.PersistentFlags().String("format", "json", "") + confirmedCtx, _ := output.WithResultStore(context.Background()) + confirmedRoot.SetContext(confirmedCtx) + confirmedRoot.SetOut(io.Discard) + confirmedRoot.SetErr(io.Discard) + confirmedService := &cobra.Command{Use: "oa"} + confirmedService.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + confirmedRoot.AddCommand(confirmedService) + confirmedRoot.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture", "--yes"}) + if err := confirmedRoot.Execute(); err != nil { + t.Fatal(err) + } + if got := strings.Join(confirmed.history, ","); got != "list_pending_approvals,list_pending_tasks,approve_processInstance,list_pending_tasks" { + t.Fatalf("call history=%s", got) + } + if len(confirmed.arguments) != 4 || confirmed.arguments[2]["processInstanceId"] != "instance-1" || confirmed.arguments[3]["processInstanceId"] != "instance-1" { + t.Fatalf("identity was not preserved: %#v", confirmed.arguments) + } +} + +func TestCrossPlatformCoverageOACommonHelperBranches(t *testing.T) { + if oaObjectResult("fixture") == nil || oaPostWriteError("oa/test", "fixture", "fixture") == nil { + t.Fatal("typed object result and post-write error must be constructed") + } + + for name, fixture := range map[string]map[string]any{ + "false with backend message": {"success": false, "errorMessage": "fixture"}, + "false without message": {"success": false}, + "null success": {"success": nil}, + "wrong success string": {"success": "TRUE"}, + } { + if err := oaRequireSuccess(fixture, "oa/test"); err == nil { + t.Errorf("%s was accepted", name) + } + } + + if _, ok := oaLookup(map[string]any{"result": "bad"}, "result.values"); ok { + t.Fatal("lookup traversed a non-object") + } + if _, ok := oaLookup(map[string]any{"result": map[string]any{}}, "result.values"); ok { + t.Fatal("lookup accepted a missing segment") + } + for name, fixture := range map[string]map[string]any{ + "nil object": {"success": true, "result": nil}, + "wrong object": {"success": true, "result": "bad"}, + "empty object": {"success": true, "result": map[string]any{}}, + } { + if value, err := oaRequireObject(fixture, "oa/test", "result"); err == nil { + t.Errorf("%s returned object %#v", name, value) + } + } + if value, err := oaRequireObject(map[string]any{"success": false}, "oa/test", "result"); err == nil { + t.Fatalf("failed envelope returned object %#v", value) + } + object, err := oaRequireObject(map[string]any{"success": true, "result": map[string]any{"id": "fixture"}}, "oa/test", "result") + if err != nil || object["id"] != "fixture" { + t.Fatalf("valid object=%#v err=%v", object, err) + } + + for name, value := range map[string]any{ + "string": " fixture ", + "json number": json.Number("42"), + "float": float64(42), + "int": int(42), + "int64": int64(42), + "unsupported": true, + } { + got := oaScalarString(value) + if name == "unsupported" && got != "" { + t.Errorf("unsupported scalar=%q", got) + } + if name != "unsupported" && got == "" { + t.Errorf("%s scalar was empty", name) + } + } + + identityFixture := map[string]any{"id": int64(42)} + if err := oaRequireIdentity(identityFixture, "oa/test", "42", "id"); err != nil { + t.Fatal(err) + } + if err := oaRequireIdentity(map[string]any{}, "oa/test", "", "id"); err == nil { + t.Fatal("missing identity was accepted") + } + if err := oaRequireIdentity(identityFixture, "oa/test", "43", "id"); err == nil { + t.Fatal("mismatched identity was accepted") + } + + formsFixture := map[string]any{"success": true, "result": []any{map[string]any{ + "processCode": "p", "name": "fixture", "iconUrl": "icon", + }}} + forms, err := oaProjectForms(formsFixture, "oa/forms", "result") + if err != nil || forms[0]["iconUrl"] != "icon" { + t.Fatalf("fallback form fields=%#v err=%v", forms, err) + } + + instanceFixture := map[string]any{"success": true, "result": map[string]any{"values": []any{map[string]any{ + "processInstanceId": "i", "processInstanceTitle": "title", "status": json.Number("2"), + "businessId": 7, "originatorUserName": "owner", "createTime": int64(9), + }}}} + instances, err := oaProjectInstances(instanceFixture, "oa/instances", "result.values") + if err != nil { + t.Fatal(err) + } + wantInstance := map[string]any{"processInstanceId": "i", "title": "title", "status": "2", "businessId": "7", "originatorName": "owner", "createTime": int64(9)} + if !reflect.DeepEqual(instances[0], wantInstance) { + t.Fatalf("projected instance=%#v want=%#v", instances[0], wantInstance) + } + + for name, fixture := range map[string]map[string]any{ + "task projection error": {"success": true, "result": map[string]any{}}, + "missing task id": {"success": true, "result": map[string]any{"taskIdList": []any{map[string]any{"x": 1}}}}, + "duplicate task id": {"success": true, "result": map[string]any{"taskIdList": []any{ + map[string]any{"taskId": 1}, map[string]any{"taskId": 1}, + }}}, + } { + if tasks, err := oaProjectTasks(fixture, "oa/tasks"); err == nil { + t.Errorf("%s returned %#v", name, tasks) + } + } + + for name, fixture := range map[string]map[string]any{ + "wrong cursor hasMore": {"hasMore": "true"}, + "terminal conflict": {"hasMore": false, "nextCursor": 1}, + } { + if page, err := oaCursorPage(fixture, "oa/cursor", 0); err == nil { + t.Errorf("%s returned %+v", name, page) + } + } + terminal, err := oaCursorPage(map[string]any{"hasMore": false}, "oa/cursor", 0) + if err != nil || !terminal.Known || terminal.HasMore || terminal.Next != "" { + t.Fatalf("terminal cursor=%+v err=%v", terminal, err) + } + if _, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 0); err == nil { + t.Fatal("continuation from invalid current page was accepted") + } + terminal, err = oaHasMorePage(map[string]any{"hasMore": false}, "oa/page", 1) + if err != nil || !terminal.Known || terminal.HasMore { + t.Fatalf("terminal numbered page=%+v err=%v", terminal, err) + } + + if err := validateOAPage(0, 1); err == nil { + t.Fatal("zero page was accepted") + } + if err := validateOAPage(1, 0); err == nil { + t.Fatal("zero limit was accepted") + } + if err := validateOAPage(1, 101); err == nil { + t.Fatal("oversized limit was accepted") + } +} + +func TestCrossPlatformCoverageOAOutputBranches(t *testing.T) { + legacyPage := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-legacy-page", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Execute: func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", []map[string]any{{"id": "1"}}, oaPageEvidence{Known: true, HasMore: true, Next: "2"}) + }, + } + _, err := runOACoverage(t, legacyPage, &oaCoverageCaller{responses: map[string][]string{}}) + if err != nil { + t.Fatal(err) + } + unknownPage := legacyPage + unknownPage.Command = "+coverage-unknown-page" + unknownPage.Execute = func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", nil, oaPageEvidence{}) + } + if _, err := runOACoverage(t, unknownPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil { + t.Fatal("unknown pagination was accepted") + } + + legacyComplete := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-legacy-complete", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Execute: func(rt *shortcut.RuntimeContext) error { return outputOACompleteCollection(rt, "items", nil) }, + } + if _, err := runOACoverage(t, legacyComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil { + t.Fatal(err) + } + + unifiedComplete := legacyComplete + unifiedComplete.Command = "+coverage-unified-complete" + unifiedComplete.OutputRollout = output.RolloutUnifiedActive + if _, err := runOACoverage(t, unifiedComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil { + t.Fatal(err) + } + + badUnifiedPage := legacyPage + badUnifiedPage.Command = "+coverage-unified-bad-page" + badUnifiedPage.OutputRollout = output.RolloutUnifiedActive + badUnifiedPage.Execute = func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", nil, oaPageEvidence{Known: true, HasMore: true}) + } + if _, err := runOACoverage(t, badUnifiedPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil { + t.Fatal("unified continuation without token was accepted") + } +} + +func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) { + expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args) + } + return caller + } + expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err != nil { + t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err) + } + return caller + } + + validPage := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}` + for name, args := range map[string][]string{ + "bad interval": {"--start", "2", "--end", "1"}, + "bad page": {"--start", "1", "--end", "2", "--page", "bad"}, + "bad limit": {"--start", "1", "--end", "2", "--limit", "bad"}, + "zero page": {"--start", "1", "--end", "2", "--page", "0"}, + } { + t.Run("list-pending validate "+name, func(t *testing.T) { + caller := expectError(t, ListPending, map[string][]string{}, args...) + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + } + t.Run("list-pending call failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {"__ERROR__"}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending projection failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending null result fails closed", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending pagination failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending full params", func(t *testing.T) { + caller := expectSuccess(t, ListPending, map[string][]string{"list_pending_approvals": {validPage}}, + "--start", "1", "--end", "2", "--page", "2", "--limit", "3", "--query", "fixture") + want := map[string]any{"starTime": 1, "endTime": 2, "pageNum": "2", "pageSize": "3", "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("list pending params=%#v want=%#v", caller.arguments[0], want) + } + }) + + for name, args := range map[string][]string{ + "negative cursor": {"--cursor", "-1"}, + "zero limit": {"--limit", "0"}, + "oversized limit": {"--limit", "101"}, + } { + t.Run("list-forms validate "+name, func(t *testing.T) { + expectError(t, ListForms, map[string][]string{}, args...) + }) + } + t.Run("list-forms call failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {"__ERROR__"}}) + }) + t.Run("list-forms projection failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run("list-forms null result fails closed", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":null}`}}) + }) + t.Run("list-forms pagination failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"processCodeList":[]}}`}}) + }) + t.Run("list-forms success", func(t *testing.T) { + expectSuccess(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":true,"nextCursor":2,"processCodeList":[{"processCode":"p","processName":"fixture"}]}}`}}, "--cursor", "1", "--limit", "2") + }) + + t.Run("search empty query", func(t *testing.T) { + caller := expectError(t, SearchForms, map[string][]string{}, "--query", " ") + if len(caller.history) != 0 { + t.Fatalf("blank query made calls: %v", caller.history) + } + }) + t.Run("search call failure", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{"search_form": {"__ERROR__"}}, "--query", "fixture") + }) + t.Run("search projection failure", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":{}}`}}, "--query", "fixture") + }) + t.Run("search known nonempty", func(t *testing.T) { + caller := expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", " fixture ") + if got := caller.arguments[0]["query"]; got != "fixture" { + t.Fatalf("normalized query=%#v, want fixture", got) + } + }) + t.Run("search legitimate empty", func(t *testing.T) { + expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[]}`}}, "--query", "guaranteed-zero-fixture") + }) + + for _, declaration := range []shortcut.Shortcut{ListExecuted, ListSubmitted, ListCc} { + declaration := declaration + tool := map[string]string{"+list-executed": "get_done_tasks", "+list-submitted": "get_submitted_instances", "+list-cc": "get_noticed_instances"}[declaration.Command] + t.Run(declaration.Command+" invalid page", func(t *testing.T) { + expectError(t, declaration, map[string][]string{}, "--page", "bad") + }) + t.Run(declaration.Command+" invalid limit", func(t *testing.T) { + expectError(t, declaration, map[string][]string{}, "--limit", "bad") + }) + t.Run(declaration.Command+" call failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}}) + }) + t.Run(declaration.Command+" full params", func(t *testing.T) { + caller := expectSuccess(t, declaration, map[string][]string{tool: {validPage}}, "--page", "2", "--limit", "3", "--query", " fixture ") + want := map[string]any{"pageNumber": "2", "pageSize": "3", "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("params=%#v want=%#v", caller.arguments[0], want) + } + }) + } +} + +func TestCrossPlatformCoverageOACompatibilityReadBranches(t *testing.T) { + expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args) + } + return caller + } + expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err != nil { + t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err) + } + return caller + } + terminal := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}` + + for _, declaration := range []shortcut.Shortcut{PendingApprovals, DoneApprovals} { + declaration := declaration + tool := map[string]string{"+pending": "list_pending_approvals", "+done-approvals": "get_done_tasks"}[declaration.Command] + t.Run(declaration.Command+" invalid limit", func(t *testing.T) { + caller := expectError(t, declaration, map[string][]string{}, "--limit", "0") + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + t.Run(declaration.Command+" call failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}}) + }) + t.Run(declaration.Command+" projection failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run(declaration.Command+" null result fails closed", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":null}`}}) + }) + t.Run(declaration.Command+" pagination failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"values":[]}}`}}) + }) + t.Run(declaration.Command+" refuses incomplete first page", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}) + }) + t.Run(declaration.Command+" default limit", func(t *testing.T) { + expectSuccess(t, declaration, map[string][]string{tool: {terminal}}) + }) + t.Run(declaration.Command+" explicit limit", func(t *testing.T) { + caller := expectSuccess(t, declaration, map[string][]string{tool: {terminal}}, "--limit", "3") + if caller.arguments[0]["pageSize"] != float64(3) { + t.Fatalf("explicit limit params=%#v", caller.arguments[0]) + } + }) + } + + for name, args := range map[string][]string{ + "bad page": {"--page", "0"}, + "bad limit": {"--limit", "101"}, + } { + t.Run("my-initiated validate "+name, func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{}, args...) + }) + } + t.Run("my-initiated call failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {"__ERROR__"}}) + }) + t.Run("my-initiated projection failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run("my-initiated null result fails closed", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":null}`}}) + }) + t.Run("my-initiated pagination failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"values":[]}}`}}) + }) + t.Run("my-initiated query and continuation", func(t *testing.T) { + caller := expectSuccess(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}, "--query", "fixture", "--page", "2", "--limit", "3") + want := map[string]any{"pageNumber": float64(2), "pageSize": float64(3), "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("my initiated params=%#v want=%#v", caller.arguments[0], want) + } + }) +} + +func TestCrossPlatformCoverageOARequiredFlagValidationClosures(t *testing.T) { + for _, tc := range []struct { + name string + flagName string + validate func(*shortcut.RuntimeContext) error + }{ + {name: "search forms", flagName: "query", validate: SearchForms.Validate}, + {name: "approve", flagName: "keyword", validate: Approve.Validate}, + } { + t.Run(tc.name, func(t *testing.T) { + declaration := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-" + strings.ReplaceAll(tc.name, " ", "-"), Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{{Name: tc.flagName, Type: shortcut.FlagString}}, + Execute: tc.validate, + } + caller := &oaCoverageCaller{responses: map[string][]string{}} + if _, err := runOACoverage(t, declaration, caller); err == nil { + t.Fatal("empty required semantic value was accepted") + } + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + } +} + +func TestCrossPlatformCoverageOAApproveFailureLedger(t *testing.T) { + pending := func(values string, hasMore bool) string { + return `{"success":true,"result":{"hasMore":` + map[bool]string{true: "true", false: "false"}[hasMore] + `,"values":` + values + `}}` + } + tasks := func(values string) string { + return `{"success":true,"result":{"taskIdList":` + values + `}}` + } + validPending := pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, false) + validTasks := tasks(`[{"taskId":1}]`) + + cases := []struct { + name string + responses map[string][]string + }{ + {name: "pending call failure", responses: map[string][]string{"list_pending_approvals": {"__ERROR__"}}}, + {name: "pending projection failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}}, + {name: "pending null result", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}}, + {name: "pending pagination failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}}, + {name: "pending is incomplete", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, true)}}}, + {name: "zero matches", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"other"}]`, false)}}}, + {name: "task call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {"__ERROR__"}}}, + {name: "task projection failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {`{"success":true,"result":{}}`}}}, + {name: "task count is not one", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[]`)}}}, + {name: "task identity is not numeric", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[{"taskId":"not-numeric"}]`)}}}, + {name: "write call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {"__ERROR__"}}}, + {name: "write business failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":false}`}}}, + {name: "write missing success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"result":{"accepted":true}}`}}}, + {name: "write null success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":null}`}}}, + {name: "readback call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, "__ERROR__"}, "approve_processInstance": {`{"success":true}`}}}, + {name: "readback malformed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, `{"success":true,"result":{}}`}, "approve_processInstance": {`{"success":true}`}}}, + {name: "write not observed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, validTasks}, "approve_processInstance": {`{"success":true}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &oaCoverageCaller{responses: tc.responses} + if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--yes"); err == nil { + t.Fatalf("failure scenario succeeded; calls=%v", caller.history) + } + }) + } + + t.Run("empty keyword validates before calls", func(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{}} + if err := runOAConfirmedCoverage(t, caller, "--keyword", " ", "--yes"); err == nil { + t.Fatal("blank keyword succeeded") + } + if len(caller.history) != 0 { + t.Fatalf("blank keyword made calls: %v", caller.history) + } + }) + + t.Run("comment is sent only after confirmation", func(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{ + "list_pending_approvals": {validPending}, + "list_pending_tasks": {validTasks, tasks(`[]`)}, + "approve_processInstance": {`{"success":true}`}, + }} + if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--comment", "approved", "--yes"); err != nil { + t.Fatal(err) + } + if caller.arguments[2]["remark"] != "approved" { + t.Fatalf("write args=%#v", caller.arguments[2]) + } + }) +} diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go new file mode 100644 index 00000000..0bacde28 --- /dev/null +++ b/internal/shortcut/oa/smart.go @@ -0,0 +1,272 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, params map[string]any) error { + operation := "oa/" + tool + data, err := rt.CallMCPData("oa", tool, params) + if err != nil { + return err + } + instances, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, _ := data["result"].(map[string]any) + page, err := oaHasMorePage(result, operation, 1) + if err != nil { + return err + } + if page.HasMore { + return oaResponseError(operation, "unavailable_continuation", "兼容 Shortcut 没有页码参数且结果仍有后续页;请改用对应可分页命令") + } + return outputOACompleteCollection(rt, collection, instances) +} + +var PendingApprovals = shortcut.Shortcut{ + Service: "oa", Command: "+pending", Product: "oa", + Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", + Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时不进入 Agent 公开发现。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)", + "兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。", + true, + oaCollectionResult("pending", "严格验证的待审批摘要"), nil, + []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +pending --limit 10", + ), + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, + Tips: []string{`dws oa +pending --limit 10`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + now := time.Now() + params := map[string]any{"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli())} + if rt.Changed("limit") { + params["pageSize"] = float64(rt.Int("limit")) + } + return oaFirstPageOnly(rt, "list_pending_approvals", "pending", params) + }, +} + +var DoneApprovals = shortcut.Shortcut{ + Service: "oa", Command: "+done-approvals", Product: "oa", + Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", + Intent: "兼容入口:读取我已处理审批的首个完整页;新调用优先使用可分页的 +list-executed。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", + "兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。", + true, + oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, + []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +done-approvals --limit 10", + ), + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, + Tips: []string{`dws oa +done-approvals --limit 10`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + limit := 20 + if rt.Changed("limit") { + limit = rt.Int("limit") + } + return oaFirstPageOnly(rt, "get_done_tasks", "done", map[string]any{"pageNumber": float64(1), "pageSize": float64(limit)}) + }, +} + +var MyInitiated = shortcut.Shortcut{ + Service: "oa", Command: "+my-initiated", Product: "oa", + Description: "列出我发起(提交)的审批单据", + Intent: "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+my-initiated", "列出我发起(提交)的审批单据", + "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + true, + oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"), + []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}, + "dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销", + ), + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"}, + {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1);--page 必须大于 0", Default: "1"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20);--limit 必须在 1-100", Default: "20"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, + }, + Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, + Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "oa/get_submitted_instances" + params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))} + if query := rt.Str("query"); query != "" { + params["query"] = query + } + data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + if err != nil { + return err + } + items, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, _ := data["result"].(map[string]any) + page, err := oaHasMorePage(result, operation, rt.Int("page")) + if err != nil { + return err + } + return outputOAPage(rt, "initiated", items, page) + }, +} + +type oaApprovalMatch struct { + id string + title string +} + +func oaMatchApprovals(items []map[string]any, keyword string) []oaApprovalMatch { + needle := strings.ToLower(strings.TrimSpace(keyword)) + matches := make([]oaApprovalMatch, 0) + for _, item := range items { + id := oaIdentity(item, "processInstanceId") + title := oaFirstString(item, "title", "processInstanceTitle") + businessID := oaIdentity(item, "businessId") + if strings.Contains(strings.ToLower(title), needle) || strings.Contains(strings.ToLower(businessID), needle) || strings.EqualFold(id, needle) { + matches = append(matches, oaApprovalMatch{id: id, title: title}) + } + } + return matches +} + +var Approve = shortcut.Shortcut{ + Service: "oa", Command: "+approve-by", Product: "oa", + Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", + Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前不进入 Agent 公开发现。", + Risk: shortcut.RiskHighWrite, + Safety: oaWriteSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+approve-by", "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", + "仅在用户明确确认同意、关键词唯一定位实例且任务读回可验证时使用;任何歧义、分页不完整或读回失败都会阻止成功。", + true, + oaWriteResult("同意审批并通过精确任务读回验证"), nil, + []contract.ParamDecl{{Name: "keyword", Property: "query"}, {Name: "comment", Property: "remark"}}, + "dws oa +approve-by --keyword 报销", + ), + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, + {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword"}, Description: "--keyword 去除空白后不能为空,且必须唯一匹配完整待办集合中的一条实例"}}, + Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + keyword := strings.TrimSpace(rt.Str("keyword")) + if keyword == "" { + return apperrors.NewValidation("--keyword 不能为空") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + keyword := strings.TrimSpace(rt.Str("keyword")) + now := time.Now() + pending, err := rt.CallMCPData("oa", "list_pending_approvals", map[string]any{ + "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli()), "query": keyword, + }) + if err != nil { + return err + } + items, err := oaProjectInstances(pending, "oa/list_pending_approvals", "result.values") + if err != nil { + return err + } + result, _ := pending["result"].(map[string]any) + page, err := oaHasMorePage(result, "oa/list_pending_approvals", 1) + if err != nil { + return err + } + if page.HasMore { + return oaResponseError("oa/list_pending_approvals", "ambiguous_incomplete_search", "待办搜索仍有后续页,无法证明关键词唯一") + } + matches := oaMatchApprovals(items, keyword) + if len(matches) != 1 { + return apperrors.NewValidation(fmt.Sprintf("关键词必须唯一匹配一条待审批实例,当前匹配 %d 条", len(matches))) + } + instanceID := matches[0].id + tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID}) + if err != nil { + return err + } + tasks, err := oaProjectTasks(tasksData, "oa/list_pending_tasks") + if err != nil { + return err + } + if len(tasks) != 1 { + return apperrors.NewValidation(fmt.Sprintf("审批实例必须唯一对应一条待处理任务,当前为 %d 条", len(tasks))) + } + taskID := tasks[0]["taskId"].(string) + numericTaskID, err := strconv.ParseFloat(taskID, 64) + if err != nil { + return oaResponseError("oa/list_pending_tasks", "malformed_task_identity", "taskId 不是可写入审批接口的数字") + } + writeArgs := map[string]any{"processInstanceId": instanceID, "taskId": numericTaskID} + if comment := rt.Str("comment"); comment != "" { + writeArgs["remark"] = comment + } + receipt, err := rt.CallMCPWriteDataStrict("oa", "approve_processInstance", writeArgs) + if err != nil { + return err + } + if err := oaRequireSuccess(receipt, "oa/approve_processInstance"); err != nil { + return err + } + readback, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID}) + if err != nil { + return oaPostWriteError("oa/list_pending_tasks", "readback_failed", "审批写入后无法读取任务状态;远端效果未知") + } + remaining, err := oaProjectTasks(readback, "oa/list_pending_tasks") + if err != nil { + return oaPostWriteError("oa/list_pending_tasks", "readback_malformed", "审批写入后的任务读回无法验证;远端效果未知") + } + for _, task := range remaining { + if task["taskId"] == taskID { + return oaPostWriteError("oa/list_pending_tasks", "write_not_observed", "审批任务写后读回仍处于待处理集合") + } + } + return rt.Output(map[string]any{"processInstanceId": instanceID, "taskId": taskID, "verified": true}) + }, +} + +func init() { + shortcut.Register(PendingApprovals, DoneApprovals, Approve, MyInitiated) +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 51dd4a94..1d81b103 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -265,10 +265,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "devapp\u0000+version-status": {}, "devapp\u0000+webapp-config": {}, "devapp\u0000+webapp-get": {}, - "ding\u0000+list": {}, - "ding\u0000+recall-personal": {}, "ding\u0000+receiver-status": {}, - "ding\u0000+send-personal": {}, "doc\u0000+access-change": {}, "doc\u0000+access-grant": {}, "doc\u0000+access-revoke": {}, @@ -377,15 +374,11 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "minutes\u0000+update": {}, "minutes\u0000+upload": {}, "minutes\u0000+upload-and-analyze": {}, - "oa\u0000+list-cc": {}, - "oa\u0000+list-executed": {}, - "oa\u0000+list-forms": {}, - "oa\u0000+list-pending": {}, - "oa\u0000+list-submitted": {}, - "oa\u0000+my-initiated": {}, "oa\u0000+search-forms": {}, "report\u0000+inbox-list": {}, "report\u0000+outbox-list": {}, + "report\u0000+report-latest": {}, + "report\u0000+template-search": {}, "sheet\u0000+list-sheets": {}, "sheet\u0000+read": {}, "todo\u0000+assign": {}, diff --git a/internal/shortcut/report/common.go b/internal/shortcut/report/common.go new file mode 100644 index 00000000..2bac4e58 --- /dev/null +++ b/internal/shortcut/report/common.go @@ -0,0 +1,477 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const reportCompositeReason = "Reviewed Report Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable report/template identities, truthful pagination, local filtering, output projection, and multi-step detail verification." + +type reportPageEvidence struct { + HasMore bool + Next string +} + +func reportReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func reportCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页严格验证的日志数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具有稳定 reportId 的日志摘要","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"creatorUserId":{"type":"string","description":"日志创建人稳定用户 ID"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"}},"required":["reportId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"服务端分页证据是否证明当前查询已结束"}},"required":["count",%q,"complete"],"additionalProperties":false}`, + description, collection, description, collection, + )), + SensitivePaths: []string{collection + ".templateName", collection + ".creatorName", collection + ".creatorUserId"}, + } +} + +func reportTemplateSearchResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的日志模板搜索结果","properties":{"count":{"type":"integer","description":"匹配模板数量"},"templates":{"type":"array","description":"名称匹配且具有稳定 ID 的模板","items":{"type":"object","description":"一条日志模板摘要","properties":{"templateId":{"type":"string","description":"稳定模板 ID"},"name":{"type":"string","description":"模板名称"},"lastModifiedTime":{"type":"integer","description":"模板最后修改时间毫秒值"}},"required":["templateId","name"],"additionalProperties":false}}},"required":["count","templates"],"additionalProperties":false}`), + SensitivePaths: []string{"templates.name"}, + } +} + +func reportLatestResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的最近日志详情","properties":{"report":{"type":"object","description":"稳定身份匹配的日志详情","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"title":{"type":"string","description":"日志标题"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"},"fields":{"type":"array","description":"严格验证的日志字段","items":{"type":"object","description":"日志字段值","properties":{"key":{"type":"string","description":"字段名称"},"value":{"type":"string","description":"字段内容"},"sort":{"type":"integer","description":"字段排序值"},"type":{"type":"integer","description":"字段类型码"}},"required":["key","value","sort","type"],"additionalProperties":false}}},"required":["reportId","fields"],"additionalProperties":false}},"required":["report"],"additionalProperties":false}`), + SensitivePaths: []string{"report.title", "report.templateName", "report.creatorName", "report.fields.value"}, + } +} + +func reportPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func reportContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "report " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "report", Name: name, CanonicalPath: "report." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: reportCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"钉钉在线文档使用 doc,待办使用 todo,OA 审批使用 oa;缺少稳定日志或模板身份时不要猜测"}, + Examples: examples, + }, + } +} + +func reportResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func reportRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return reportResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 Report 调用成功或结果确实为空") + } + raw, present := data["success"] + if !present { + return reportResponseError(operation, "missing_success", "Report 响应缺少 success 业务状态") + } + success, ok := raw.(bool) + if !ok { + return reportResponseError(operation, "invalid_success_type", fmt.Sprintf("Report success 应为布尔值,实际为 %T", raw)) + } + if success { + return nil + } + message := "Report 服务明确返回失败" + for _, key := range []string{"errorMsg", "errorMessage", "message"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + message = strings.TrimSpace(value) + break + } + } + return reportResponseError(operation, "remote_failure", message) +} + +func reportObjectCollection(value any, operation, path string) ([]map[string]any, error) { + raw, ok := value.([]any) + if !ok { + return nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func reportListCollection(data map[string]any, operation string) ([]map[string]any, map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, nil, err + } + result, present := data["result"] + if !present { + return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result 日志集合") + } + if _, ok := result.([]any); ok { + items, err := reportObjectCollection(result, operation, "result") + return items, data, err + } + container, ok := result.(map[string]any) + if !ok || container == nil { + return nil, nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report result 应为数组或带 report_list 的对象,实际为 %T", result)) + } + raw, present := container["report_list"] + if !present { + return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result.report_list 数组") + } + items, err := reportObjectCollection(raw, operation, "result.report_list") + return items, container, err +} + +func reportRequiredString(item map[string]any, operation string, index int, keys ...string) (string, error) { + var selected string + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := raw.(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 必须是非空字符串", index, key)) + } + if selected != "" && selected != value { + return "", reportResponseError(operation, "conflicting_item_identity", fmt.Sprintf("Report 结果第 %d 项的身份字段冲突", index)) + } + selected = value + } + if selected == "" { + return "", reportResponseError(operation, "missing_item_identity", fmt.Sprintf("Report 结果第 %d 项缺少稳定身份", index)) + } + return selected, nil +} + +func reportOptionalString(item map[string]any, operation string, index int, keys ...string) (string, error) { + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := raw.(string) + if !ok { + return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为字符串,实际为 %T", index, key, raw)) + } + return strings.TrimSpace(value), nil + } + return "", nil +} + +func reportInteger(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 { + return 0, false + } + return int64(typed), true + case int: + return int64(typed), true + case int64: + return typed, true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + default: + return 0, false + } +} + +func reportOptionalInteger(item map[string]any, operation string, index int, keys ...string) (int64, bool, error) { + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := reportInteger(raw) + if !ok { + return 0, false, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为整数", index, key)) + } + return value, true, nil + } + return 0, false, nil +} + +func reportProjectEntries(data map[string]any, operation string) ([]map[string]any, reportPageEvidence, error) { + items, pageContainer, err := reportListCollection(data, operation) + if err != nil { + return nil, reportPageEvidence{}, err + } + entries := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := reportRequiredString(item, operation, index, "reportId", "report_id", "report_Id") + if identityErr != nil { + return nil, reportPageEvidence{}, identityErr + } + if _, duplicate := seen[id]; duplicate { + return nil, reportPageEvidence{}, reportResponseError(operation, "duplicate_item_identity", "当前 Report 页包含重复 reportId") + } + seen[id] = struct{}{} + projected := map[string]any{"reportId": id} + for target, aliases := range map[string][]string{ + "templateName": {"templateName", "template_name", "reportTemplateName", "report_template_name"}, + "creatorName": {"creatorName", "creator_name", "creatorUserName", "senderName", "authorName"}, + "creatorUserId": {"creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"}, + } { + value, valueErr := reportOptionalString(item, operation, index, aliases...) + if valueErr != nil { + return nil, reportPageEvidence{}, valueErr + } + if value != "" { + projected[target] = value + } + } + for target, aliases := range map[string][]string{ + "createTime": {"createTime", "create_time", "gmtCreate", "sendTime"}, + "modifiedTime": {"modifiedTime", "modified_time", "gmtModified", "modifyTime"}, + } { + value, present, valueErr := reportOptionalInteger(item, operation, index, aliases...) + if valueErr != nil { + return nil, reportPageEvidence{}, valueErr + } + if present { + projected[target] = value + } + } + entries = append(entries, projected) + } + page, err := reportPage(data, pageContainer, operation, len(entries)) + if err != nil { + return nil, reportPageEvidence{}, err + } + return entries, page, nil +} + +func reportPage(data, result map[string]any, operation string, count int) (reportPageEvidence, error) { + raw, present := data["hasMore"] + if nested, exists := result["hasMore"]; exists { + if present && !sameReportScalar(raw, nested) { + return reportPageEvidence{}, reportResponseError(operation, "conflicting_pagination", "Report 顶层与 result.hasMore 冲突") + } + raw, present = nested, true + } + if !present { + return reportPageEvidence{}, reportResponseError(operation, "missing_pagination", "Report 列表缺少 hasMore,不能把当前页宣称为完整结果") + } + hasMore, ok := raw.(bool) + if !ok { + return reportPageEvidence{}, reportResponseError(operation, "malformed_pagination", "Report hasMore 应为布尔值") + } + page := reportPageEvidence{HasMore: hasMore} + next, nextPresent, err := reportNextCursor(data, result, operation) + if err != nil { + return reportPageEvidence{}, err + } + if !hasMore { + // The Report service echoes an integer cursor on terminal pages. It is a + // page receipt, not a continuation: hasMore=false is authoritative and + // the unified projection intentionally omits next_token. Parsing above + // still rejects wrong types and conflicting cursor fields. + return page, nil + } + if count == 0 { + return reportPageEvidence{}, reportResponseError(operation, "empty_page_with_continuation", "Report 空页仍声明 hasMore=true") + } + if !nextPresent || next <= 0 { + return reportPageEvidence{}, reportResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 continuation cursor") + } + page.Next = strconv.FormatInt(next, 10) + return page, nil +} + +func reportNextCursor(data, result map[string]any, operation string) (int64, bool, error) { + var selected int64 + found := false + for _, object := range []map[string]any{data, result} { + for _, key := range []string{"nextCursor", "cursor"} { + raw, present := object[key] + if !present || raw == nil { + continue + } + value, ok := reportInteger(raw) + if !ok { + return 0, false, reportResponseError(operation, "malformed_pagination", fmt.Sprintf("Report %s 应为整数", key)) + } + if found && selected != value { + return 0, false, reportResponseError(operation, "conflicting_pagination", "Report continuation cursor 字段冲突") + } + selected, found = value, true + } + } + return selected, found, nil +} + +func sameReportScalar(left, right any) bool { + leftJSON, leftErr := json.Marshal(left) + rightJSON, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON) +} + +func reportProjectTemplates(data map[string]any, operation string) ([]map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, err + } + raw, present := data["items"] + if !present { + return nil, reportResponseError(operation, "missing_collection", "Report 模板响应缺少 items 数组") + } + items, err := reportObjectCollection(raw, operation, "items") + if err != nil { + return nil, err + } + templates := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := reportRequiredString(item, operation, index, "report_template_id") + if identityErr != nil { + return nil, identityErr + } + name, nameErr := reportRequiredString(item, operation, index, "report_template_name") + if nameErr != nil { + return nil, nameErr + } + if _, duplicate := seen[id]; duplicate { + return nil, reportResponseError(operation, "duplicate_item_identity", "Report 模板响应包含重复 templateId") + } + seen[id] = struct{}{} + projected := map[string]any{"templateId": id, "name": name} + modified, present, modifiedErr := reportOptionalInteger(item, operation, index, "last_modified_time") + if modifiedErr != nil { + return nil, modifiedErr + } + if present { + projected["lastModifiedTime"] = modified + } + templates = append(templates, projected) + } + return templates, nil +} + +func reportProjectDetail(data map[string]any, operation, expectedID string) (map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, err + } + result, ok := data["result"].(map[string]any) + if !ok || len(result) == 0 { + return nil, reportResponseError(operation, "missing_result", "Report 详情成功响应缺少非空 result 对象") + } + id, err := reportRequiredString(result, operation, 0, "report_Id", "reportId", "report_id") + if err != nil { + return nil, err + } + if id != expectedID { + return nil, reportResponseError(operation, "identity_mismatch", "Report 详情 reportId 与请求目标不一致") + } + projected := map[string]any{"reportId": id} + for target, aliases := range map[string][]string{ + "title": {"report_name", "reportName", "title"}, + "templateName": {"report_template_name", "templateName"}, + "creatorName": {"creatorName", "senderName"}, + } { + value, valueErr := reportOptionalString(result, operation, 0, aliases...) + if valueErr != nil { + return nil, valueErr + } + if value != "" { + projected[target] = value + } + } + for target, aliases := range map[string][]string{ + "createTime": {"createTime", "create_time"}, "modifiedTime": {"modifiedTime", "modified_time"}, + } { + value, present, valueErr := reportOptionalInteger(result, operation, 0, aliases...) + if valueErr != nil { + return nil, valueErr + } + if present { + projected[target] = value + } + } + rawFields, present := result["report_content"] + if !present { + return nil, reportResponseError(operation, "missing_collection", "Report 详情缺少 result.report_content 数组") + } + fields, err := reportObjectCollection(rawFields, operation, "result.report_content") + if err != nil { + return nil, err + } + projectedFields := make([]map[string]any, 0, len(fields)) + for index, field := range fields { + key, keyErr := reportRequiredString(field, operation, index, "key") + if keyErr != nil { + return nil, keyErr + } + rawValue, valuePresent := field["value"] + value, valueOK := rawValue.(string) + if !valuePresent || !valueOK { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 value 必须是字符串", index)) + } + sortValue, sortOK := reportInteger(field["sort"]) + typeValue, typeOK := reportInteger(field["type"]) + if !sortOK || !typeOK { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 sort/type 必须是整数", index)) + } + projectedFields = append(projectedFields, map[string]any{"key": key, "value": value, "sort": sortValue, "type": typeValue}) + } + projected["fields"] = projectedFields + return projected, nil +} + +func outputReportPage(rt *shortcut.RuntimeContext, collection string, entries []map[string]any, page reportPageEvidence) error { + payload := map[string]any{"count": len(entries), collection: entries, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextCursor"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return reportResponseError("report/pagination", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(entries)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} diff --git a/internal/shortcut/report/latest.go b/internal/shortcut/report/latest.go new file mode 100644 index 00000000..1d633c5b --- /dev/null +++ b/internal/shortcut/report/latest.go @@ -0,0 +1,130 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var ReportLatest = shortcut.Shortcut{ + Service: "report", Command: "+report-latest", Product: "report", + Description: "读取我最近提交的一篇日志详情", + Intent: "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+report-latest", "读取我最近提交的一篇日志详情", + "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。", + reportLatestResult(), nil, + []contract.ParamDecl{ + {Name: "keyword", Property: "report_template_name"}, + {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + }, + "dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`, + ), + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + }, + Tips: []string{"dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("start") != rt.Changed("end") { + return apperrors.NewValidation("--start 与 --end 必须同时提供", apperrors.WithReason("incomplete_creation_range")) + } + if rt.Changed("start") { + _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays) + return err + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const listOperation = "report/get_send_report_list" + now := time.Now() + end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + start := end.Add(-reportMaximumOutboxDays * 24 * time.Hour) + if rt.Changed("start") { + startMillis, endMillis, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays) + if err != nil { + return err + } + start, end = time.UnixMilli(startMillis), time.UnixMilli(endMillis) + } + params := map[string]any{ + "cursor": 0, "size": 20, + "startTime": start.UnixMilli(), "endTime": end.UnixMilli(), + } + if keyword := strings.TrimSpace(rt.Str("keyword")); keyword != "" { + params["report_template_name"] = keyword + } + data, err := rt.CallMCPData("report", "get_send_report_list", params) + if err != nil { + return err + } + entries, page, err := reportProjectEntries(data, listOperation) + if err != nil { + return err + } + if page.HasMore { + return reportResponseError(listOperation, "incomplete_latest_candidates", "发件箱仍有后续页,不能从不完整集合宣称最新日志") + } + if len(entries) == 0 { + return apperrors.NewValidation("最近 20 天没有可验证的已发送日志", apperrors.WithReason("no_sent_report_fixture")) + } + latestID, err := reportLatestEntryID(entries, listOperation) + if err != nil { + return err + } + const detailOperation = "report/get_report_entry_details" + detailData, err := rt.CallMCPData("report", "get_report_entry_details", map[string]any{"report_id": latestID}) + if err != nil { + return err + } + detail, err := reportProjectDetail(detailData, detailOperation, latestID) + if err != nil { + return err + } + return rt.Output(map[string]any{"report": detail}) + }, +} + +func reportLatestEntryID(entries []map[string]any, operation string) (string, error) { + var latestID string + var latestTime int64 + latestCount := 0 + for index, entry := range entries { + created, ok := entry["createTime"].(int64) + if !ok || created <= 0 { + return "", reportResponseError(operation, "missing_latest_order", "发件箱项目缺少正整数 createTime,不能证明哪一篇最新") + } + id, ok := entry["reportId"].(string) + if !ok || strings.TrimSpace(id) == "" { + return "", reportResponseError(operation, "missing_item_identity", "发件箱项目缺少稳定 reportId") + } + if index == 0 || created > latestTime { + latestID, latestTime = id, created + latestCount = 1 + } else if created == latestTime { + latestCount++ + } + } + if latestCount != 1 { + return "", reportResponseError(operation, "ambiguous_latest_order", "多篇日志具有相同的最高 createTime,不能确定唯一最新日志") + } + return latestID, nil +} + +func init() { + shortcut.Register(ReportLatest) +} diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go index 28aa7938..9fb8c52b 100644 --- a/internal/shortcut/report/report.go +++ b/internal/shortcut/report/report.go @@ -1,103 +1,126 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package report declares declarative shortcuts for the DingTalk -// "报告/日志" (OA 周报应用) MCP tools. Each shortcut is a thin wrapper over a -// single MCP tool call; tool names and parameter keys mirror the DWS report -// helper (internal/helpers/report.go) verbatim. +// Package report registers strict declarative shortcuts for DingTalk reports. package report import ( "fmt" + "strconv" "strings" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -// reportParseISOMillis parses an ISO-8601 timestamp (e.g. 2026-03-10T00:00:00+08:00) -// into Unix milliseconds, matching the helper's parseISOTimeToMillis behavior. -func reportParseISOMillis(name, s string) (int64, error) { - s = strings.TrimSpace(s) - t, err := time.Parse(time.RFC3339, s) +const ( + reportMaximumInboxDays = 180 + reportMaximumOutboxDays = 20 +) + +func reportParseISOMillis(name, value string) (int64, error) { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) if err != nil { - return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err) + return 0, apperrors.NewValidation( + fmt.Sprintf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00)", name), + apperrors.WithReason("invalid_time_format"), + ) } - return t.UnixMilli(), nil + return parsed.UnixMilli(), nil +} + +func reportValidatePage(cursor, size int) error { + if cursor < 0 { + return apperrors.NewValidation("--cursor 不能小于 0", apperrors.WithReason("invalid_cursor")) + } + if size < 1 || size > 20 { + return apperrors.NewValidation("--size 必须在 1 到 20 之间", apperrors.WithReason("invalid_page_size")) + } + return nil +} + +func reportValidateRange(startName, startValue, endName, endValue string, maximumDays int) (int64, int64, error) { + start, err := reportParseISOMillis(startName, startValue) + if err != nil { + return 0, 0, err + } + end, err := reportParseISOMillis(endName, endValue) + if err != nil { + return 0, 0, err + } + if end <= start { + return 0, 0, apperrors.NewValidation("结束时间必须晚于开始时间", apperrors.WithReason("invalid_time_range")) + } + if maximumDays > 0 && end-start > int64(maximumDays)*int64(24*time.Hour/time.Millisecond) { + return 0, 0, apperrors.NewValidation( + fmt.Sprintf("时间范围不能超过 %d 天", maximumDays), + apperrors.WithReason("time_range_too_large"), + ) + } + return start, end, nil +} + +func reportValidateContinuation(page reportPageEvidence, current int, operation string) error { + if !page.HasMore { + return nil + } + next, err := strconv.ParseInt(page.Next, 10, 64) + if err != nil || next <= int64(current) { + return reportResponseError(operation, "stalled_cursor", "Report continuation cursor 没有严格前进") + } + return nil } -// TemplateList lists the report templates available to the current user. -// TemplateGet reads a single report template's field definitions by name. -// InboxList lists reports received by the current user within a time window. var InboxList = shortcut.Shortcut{ - Service: "report", - Command: "+inbox-list", - Product: "report", - Description: "列出我收到的日报(按时间范围分页)", - Intent: "当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "report", - Name: "shortcut_inbox_list", - CanonicalPath: "report.shortcut_inbox_list", - CLIPath: "report +inbox-list", - PrimaryCLIPath: "report +inbox-list", + Service: "report", Command: "+inbox-list", Product: "report", + Description: "列出我收到的日志", + Intent: "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+inbox-list", "列出我收到的日志", + "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", + reportCollectionResult("reports", "严格验证的收件箱日志页"), reportPagination(), + []contract.ParamDecl{ + {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, + {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, + {Name: "sender-user-ids", Property: "senderUserIds", InterfaceType: "array"}, }, - Description: "列出我收到的日报(按时间范围分页)", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "列出我收到的日报(按时间范围分页)", - UseWhen: []string{"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"}, - }, - }, + "dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20", + ), Flags: []shortcut.Flag{ - {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)", Required: true}, - {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, - {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表,逗号分隔,过滤指定发送人"}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"}, + {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表"}, }, - Tips: []string{ - `dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "结束时间必须晚于开始时间,跨度不得超过 180 天"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - startMs, err := reportParseISOMillis("start", rt.Str("start")) - if err != nil { + Tips: []string{`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil { return err } - endMs, err := reportParseISOMillis("end", rt.Str("end")) + _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays) + return err + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_received_report_list" + start, end, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays) if err != nil { return err } params := map[string]any{ - "startTime": startMs, - "endTime": endMs, - "cursor": rt.Int("cursor"), - "size": rt.Int("size"), + "startTime": start, "endTime": end, + "cursor": rt.Int("cursor"), "size": rt.Int("size"), } if rt.Changed("sender-user-ids") { params["senderUserIds"] = rt.StrSlice("sender-user-ids") @@ -106,192 +129,164 @@ var InboxList = shortcut.Shortcut{ if err != nil { return err } - reports := reportEntryListProject(data) - return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + reports, page, err := reportProjectEntries(data, operation) + if err != nil { + return err + } + if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil { + return err + } + return outputReportPage(rt, "reports", reports, page) }, } -// reportEntryListProject reshapes the raw report-list responses -// (get_received_report_list / get_send_report_list) into a clean, stable list -// of report summaries — the output-projection fidelity the framework applies to -// every list command. Both the list container and the per-item field names are -// probed defensively across candidate keys so the projection tolerates -// response-shape drift and never fabricates data: an empty/unknown shape yields -// an empty list rather than a crash. -func reportEntryListProject(data map[string]any) []map[string]any { - raw := reportEntryListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := reportEntryListFirst(m, "reportId", "report_id", "id"); ok { - row["reportId"] = v - } - if v, ok := reportEntryListFirst(m, "templateName", "template_name", "reportTemplateName"); ok { - row["templateName"] = v - } - if v, ok := reportEntryListFirst(m, "creatorName", "creator_name", "creatorUserName", "senderName", "authorName"); ok { - row["creatorName"] = v - } - if v, ok := reportEntryListFirst(m, "creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"); ok { - row["creatorUserId"] = v - } - if v, ok := reportEntryListFirst(m, "createTime", "create_time", "gmtCreate", "sendTime"); ok { - row["createTime"] = v - } - if v, ok := reportEntryListFirst(m, "modifiedTime", "modified_time", "gmtModified", "modifyTime"); ok { - row["modifiedTime"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// reportEntryListResolveList locates the list payload inside the response, -// tolerating a bare top-level array or nesting one level under a common -// envelope key. -func reportEntryListResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // get_received_report_list / get_send_report_list nest the list under - // result.report_list (snake_case); "report_list" MUST be probed — the - // camelCase "reportList" alone leaves +inbox-list / +outbox-list silently - // empty despite the backend returning reports. - for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// reportEntryListFirst returns the first present candidate key's value. -func reportEntryListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - -// OutboxList lists reports sent/created by the current user. var OutboxList = shortcut.Shortcut{ - Service: "report", - Command: "+outbox-list", - Product: "report", - Description: "列出我发出的日报(可选时间/模版名过滤)", - Intent: "当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "report", - Name: "shortcut_outbox_list", - CanonicalPath: "report.shortcut_outbox_list", - CLIPath: "report +outbox-list", - PrimaryCLIPath: "report +outbox-list", + Service: "report", Command: "+outbox-list", Product: "report", + Description: "列出我发出的日志", + Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+outbox-list", "列出我发出的日志", + "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。", + reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(), + []contract.ParamDecl{ + {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, + {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, + {Name: "modified-start", Property: "modifiedStart"}, {Name: "modified-end", Property: "modifiedEnd"}, + {Name: "template-name", Property: "templateName"}, }, - Description: "列出我发出的日报(可选时间/模版名过滤)", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "列出我发出的日报(可选时间/模版名过滤)", - UseWhen: []string{"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{ - "dws report +outbox-list --cursor 0 --size 20", - "dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\"", - }, - }, - }, + "dws report +outbox-list --cursor 0 --size 20", + ), Flags: []shortcut.Flag{ - {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, - {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601 (可选,服务端单次跨度上限 20 天)"}, - {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601 (可选)"}, - {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601 (可选)"}, - {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601 (可选)"}, - {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称 (可选,不传查全部)"}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"}, + {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"}, + {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"}, + {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称"}, }, - Tips: []string{ - `dws report +outbox-list --cursor 0 --size 20`, - `dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "创建时间范围必须有效且不得超过 20 天"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"modified-start", "modified-end"}, Description: "修改时间必须成对提供、范围有效且不得超过 20 天"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "cursor": rt.Int("cursor"), - "size": rt.Int("size"), + Tips: []string{"dws report +outbox-list --cursor 0 --size 20"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil { + return err } - if rt.Changed("start") { - ms, err := reportParseISOMillis("start", rt.Str("start")) - if err != nil { + if rt.Changed("start") && rt.Changed("end") { + if _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays); err != nil { return err } - params["startTime"] = ms } - if rt.Changed("end") { - ms, err := reportParseISOMillis("end", rt.Str("end")) - if err != nil { - return err - } - params["endTime"] = ms + if rt.Changed("modified-start") != rt.Changed("modified-end") { + return apperrors.NewValidation("--modified-start 与 --modified-end 必须同时提供", apperrors.WithReason("incomplete_modified_range")) } if rt.Changed("modified-start") { - ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start")) - if err != nil { - return err - } - params["modifiedStartTime"] = ms + _, _, err := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays) + return err } - if rt.Changed("modified-end") { - ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end")) - if err != nil { - return err - } - params["modifiedEndTime"] = ms + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_send_report_list" + now := time.Now() + defaultEnd := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + startValue := defaultEnd.Add(-reportMaximumOutboxDays * 24 * time.Hour).Format(time.RFC3339) + endValue := defaultEnd.Format(time.RFC3339) + if rt.Changed("start") { + startValue = rt.Str("start") } - if rt.Changed("template-name") { - params["report_template_name"] = rt.Str("template-name") + if rt.Changed("end") { + endValue = rt.Str("end") + } + start, end, err := reportValidateRange("start", startValue, "end", endValue, reportMaximumOutboxDays) + if err != nil { + return err + } + params := map[string]any{ + "cursor": rt.Int("cursor"), "size": rt.Int("size"), + "startTime": start, "endTime": end, + } + if rt.Changed("modified-start") { + modifiedStart, modifiedEnd, rangeErr := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays) + if rangeErr != nil { + return rangeErr + } + params["modifiedStartTime"], params["modifiedEndTime"] = modifiedStart, modifiedEnd + } + if name := strings.TrimSpace(rt.Str("template-name")); name != "" { + params["report_template_name"] = name } data, err := rt.CallMCPData("report", "get_send_report_list", params) if err != nil { return err } - reports := reportEntryListProject(data) - return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + reports, page, err := reportProjectEntries(data, operation) + if err != nil { + return err + } + if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil { + return err + } + return outputReportPage(rt, "reports", reports, page) }, } -// EntryGet reads the full body of a single report by id. -// EntryStats reads the read-receipt statistics of a single report. -// EntrySubmit submits a new report against a template. +var TemplateSearch = shortcut.Shortcut{ + Service: "report", Command: "+template-search", Product: "report", + Description: "按名称搜索可用日志模板", + Intent: "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+template-search", "按名称搜索可用日志模板", + "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。", + reportTemplateSearchResult(), nil, + []contract.ParamDecl{{Name: "query", Property: "report_template_name"}}, + "dws report +template-search --query 周报", + ), + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "模板名称关键词,不区分大小写;--query 不能为空", Required: true}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空"}}, + Tips: []string{"dws report +template-search --query 周报"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return apperrors.NewValidation("--query 不能为空", apperrors.WithReason("empty_query")) + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_available_report_templates" + data, err := rt.CallMCPData("report", "get_available_report_templates", map[string]any{}) + if err != nil { + return err + } + templates, err := reportProjectTemplates(data, operation) + if err != nil { + return err + } + query := strings.ToLower(strings.TrimSpace(rt.Str("query"))) + matches := make([]map[string]any, 0) + for _, template := range templates { + if strings.Contains(strings.ToLower(template["name"].(string)), query) { + matches = append(matches, template) + } + } + payload := map[string]any{"count": len(matches), "templates": matches} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(matches))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) + }, +} + func init() { - shortcut.Register( - InboxList, - OutboxList, - ) + shortcut.Register(InboxList, OutboxList, TemplateSearch) } diff --git a/internal/shortcut/report/report_list_projection_test.go b/internal/shortcut/report/report_list_projection_test.go deleted file mode 100644 index faa8c95e..00000000 --- a/internal/shortcut/report/report_list_projection_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package report - -import ( - "encoding/json" - "testing" -) - -// TestReportEntryListResolveListSnakeShape guards against projection-data-loss: -// get_received_report_list / get_send_report_list nest the list under -// result.report_list (snake_case); the resolver must probe "report_list" or -// +inbox-list / +outbox-list silently return empty despite the backend having -// reports. -func TestReportEntryListResolveListSnakeShape(t *testing.T) { - const raw = `{"result":{"report_list":[ - {"reportId":"r1","templateName":"daily report"}, - {"reportId":"r2","templateName":"weekly report"} - ]}}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if got := reportEntryListResolveList(data); len(got) != 2 { - t.Fatalf("lower/upper mismatch: result.report_list has 2 entries, resolver returned %d", len(got)) - } -} diff --git a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..48fae36f --- /dev/null +++ b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go @@ -0,0 +1,625 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type reportCoverageCall struct { + tool string + args map[string]any +} + +type reportCoverageCaller struct { + responses map[string][]string + history []reportCoverageCall +} + +func (caller *reportCoverageCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, reportCoverageCall{tool: tool, args: args}) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing Report fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*reportCoverageCaller) Format() string { return "json" } +func (*reportCoverageCaller) DryRun() bool { return false } +func (*reportCoverageCaller) Fields() string { return "" } +func (*reportCoverageCaller) JQ() string { return "" } + +func runReportCoverage(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func directReportRuntime(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) *shortcut.RuntimeContext { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageReportContractsAreStrictTypedAndUnified(t *testing.T) { + declarations := []shortcut.Shortcut{InboxList, OutboxList, TemplateSearch, ReportLatest} + for _, declaration := range declarations { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks Contract/Result", declaration.Command) + } + if declaration.Safety.Effect != "read" || declaration.Safety.Confirmation != "not_required" { + t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface) + } + } + if InboxList.Contract.Pagination == nil || OutboxList.Contract.Pagination == nil { + t.Fatal("Report list shortcuts must publish cursor pagination") + } + if TemplateSearch.Contract.Pagination != nil || ReportLatest.Contract.Pagination != nil { + t.Fatal("non-paginated Report shortcuts published pagination") + } +} + +func TestCrossPlatformCoverageReportListResponseMatrix(t *testing.T) { + valid := map[string]any{ + "success": true, + "result": map[string]any{ + "report_list": []any{map[string]any{"reportId": "report-1", "templateName": "fixture", "createTime": float64(10)}}, + }, + "hasMore": false, + } + entries, page, err := reportProjectEntries(valid, "report/get_received_report_list") + if err != nil || len(entries) != 1 || page.HasMore || entries[0]["createTime"] != int64(10) { + t.Fatalf("valid projection entries=%#v page=%+v err=%v", entries, page, err) + } + empty := map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "cursor": nil} + if entries, page, err := reportProjectEntries(empty, "report/get_received_report_list"); err != nil || len(entries) != 0 || page.HasMore { + t.Fatalf("terminal empty entries=%#v page=%+v err=%v", entries, page, err) + } + terminalReceipt := map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": false, "nextCursor": float64(1)} + if entries, page, err := reportProjectEntries(terminalReceipt, "report/get_received_report_list"); err != nil || len(entries) != 1 || page.HasMore || page.Next != "" { + t.Fatalf("terminal cursor receipt entries=%#v page=%+v err=%v", entries, page, err) + } + fixtures := map[string]map[string]any{ + "empty response": map[string]any{}, + "missing success": map[string]any{"result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "wrong success": map[string]any{"success": "true", "result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "remote failure": map[string]any{"success": false, "result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "missing result": map[string]any{"success": true, "hasMore": false}, + "wrong result": map[string]any{"success": true, "result": "bad", "hasMore": false}, + "missing collection": map[string]any{"success": true, "result": map[string]any{}, "hasMore": false}, + "wrong collection": map[string]any{"success": true, "result": map[string]any{"report_list": map[string]any{}}, "hasMore": false}, + "bad item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{"bad"}}, "hasMore": false}, + "empty item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{}}}, "hasMore": false}, + "missing identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"createTime": float64(1)}}}, "hasMore": false}, + "wrong identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": float64(1)}}}, "hasMore": false}, + "duplicate identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "same"}, map[string]any{"reportId": "same"}}}, "hasMore": false}, + "wrong optional": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "createTime": "1"}}}, "hasMore": false}, + "missing pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}}, + "wrong pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": "false"}, + "empty continuation": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": true, "nextCursor": float64(2)}, + "missing continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, + }, + "wrong continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, "nextCursor": "2", + }, + "wrong terminal cursor": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "nextCursor": "2", + }, + "conflicting has more": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{}, "hasMore": true}, "hasMore": false, + }, + "conflicting continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}, "cursor": float64(3)}, "hasMore": true, "nextCursor": float64(2), + }, + } + for name, fixture := range fixtures { + if projected, _, projectErr := reportProjectEntries(fixture, "report/get_received_report_list"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "2"}, 2, "report/list"); err == nil { + t.Fatal("stalled continuation returned success") + } +} + +func TestCrossPlatformCoverageReportTemplateResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "items": []any{ + map[string]any{"report_template_id": "template-1", "report_template_name": "Fixture Weekly", "last_modified_time": float64(2)}, + map[string]any{"report_template_id": "template-2", "report_template_name": "Fixture Daily"}, + }} + templates, err := reportProjectTemplates(valid, "report/get_available_report_templates") + if err != nil || len(templates) != 2 || templates[0]["lastModifiedTime"] != int64(2) { + t.Fatalf("valid templates=%#v err=%v", templates, err) + } + for name, fixture := range map[string]map[string]any{ + "empty response": {}, + "missing success": {"items": []any{}}, + "wrong success": {"success": float64(1), "items": []any{}}, + "missing collection": {"success": true}, + "wrong collection": {"success": true, "items": map[string]any{}}, + "bad item": {"success": true, "items": []any{"bad"}}, + "missing id": {"success": true, "items": []any{map[string]any{"report_template_name": "fixture"}}}, + "missing name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1"}}}, + "wrong name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": float64(1)}}}, + "duplicate id": {"success": true, "items": []any{map[string]any{"report_template_id": "same", "report_template_name": "one"}, map[string]any{"report_template_id": "same", "report_template_name": "two"}}}, + "wrong modified": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": "fixture", "last_modified_time": "2"}}}, + } { + if projected, projectErr := reportProjectTemplates(fixture, "report/get_available_report_templates"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if projected, err := reportProjectTemplates(map[string]any{"success": true, "items": []any{}}, "report/templates"); err != nil || len(projected) != 0 { + t.Fatalf("explicit empty template collection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageReportDetailResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{ + "report_Id": "report-2", "report_name": "fixture", "createTime": float64(2), + "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": float64(1), "type": float64(2)}}, + }} + detail, err := reportProjectDetail(valid, "report/get_report_entry_details", "report-2") + if err != nil || detail["reportId"] != "report-2" || len(detail["fields"].([]map[string]any)) != 1 { + t.Fatalf("valid detail=%#v err=%v", detail, err) + } + for name, fixture := range map[string]map[string]any{ + "empty response": {}, + "missing result": {"success": true}, + "empty result": {"success": true, "result": map[string]any{}}, + "missing id": {"success": true, "result": map[string]any{"report_content": []any{}}}, + "identity mismatch": {"success": true, "result": map[string]any{"report_Id": "other", "report_content": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{"report_Id": "report-2"}}, + "wrong collection": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": map[string]any{}}}, + "bad field": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{"bad"}}}, + "missing field key": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"value": "value", "sort": float64(1), "type": float64(2)}}}}, + "wrong field type": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": "1", "type": float64(2)}}}}, + } { + if projected, projectErr := reportProjectDetail(fixture, "report/get_report_entry_details", "report-2"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageReportExactShortcutsProjectUnifiedData(t *testing.T) { + templateCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_available_report_templates": {`{"success":true,"items":[{"report_template_id":"template-1","report_template_name":"Fixture Weekly","last_modified_time":2},{"report_template_id":"template-2","report_template_name":"Fixture Daily"}]}`}, + }} + cmd, err := runReportCoverage(t, TemplateSearch, templateCaller, "--query", "weekly") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 { + t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data := envelope["data"].(map[string]any) + if data["count"] != float64(1) || len(data["templates"].([]any)) != 1 { + t.Fatalf("template search data=%#v", data) + } + if _, leaked := data["items"]; leaked { + t.Fatalf("template search leaked raw collection: %#v", data) + } + + inboxCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false,"cursor":null}`}, + }} + cmd, err = runReportCoverage(t, InboxList, inboxCaller, + "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00") + if err != nil { + t.Fatal(err) + } + stdout.Reset() + cmd.SetOut(&stdout) + if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { + t.Fatalf("inbox emit=(%t,%v)", emitted, emitErr) + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data = envelope["data"].(map[string]any) + if data["count"] != float64(1) || data["complete"] != true { + t.Fatalf("inbox data=%#v", data) + } +} + +func TestCrossPlatformCoverageReportLatestRequiresCompleteOrderedListAndExactReadback(t *testing.T) { + missingOrder := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":false}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, missingOrder); err == nil || len(missingOrder.history) != 1 { + t.Fatalf("missing order err=%v history=%v", err, missingOrder.history) + } + + incomplete := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":true,"nextCursor":20}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, incomplete); err == nil || len(incomplete.history) != 1 { + t.Fatalf("incomplete err=%v history=%v", err, incomplete.history) + } + + tied := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":2},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, tied); err == nil || len(tied.history) != 1 { + t.Fatalf("tied latest err=%v history=%v", err, tied.history) + } + + caller := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, + "get_report_entry_details": {`{"success":true,"result":{"report_Id":"report-2","report_name":"fixture","createTime":2,"report_content":[{"key":"field","value":"value","sort":1,"type":2}]}}`}, + }} + cmd, err := runReportCoverage(t, ReportLatest, caller, + "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-20T00:00:00+08:00") + if err != nil { + t.Fatal(err) + } + if len(caller.history) != 2 || caller.history[0].tool != "get_send_report_list" || caller.history[1].tool != "get_report_entry_details" || caller.history[1].args["report_id"] != "report-2" { + t.Fatalf("exact call history=%#v", caller.history) + } + if caller.history[0].args["startTime"] != int64(1782835200000) || caller.history[0].args["endTime"] != int64(1784476800000) { + t.Fatalf("explicit range args=%#v", caller.history[0].args) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { + t.Fatalf("latest emit=(%t,%v)", emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + projected := envelope["data"].(map[string]any)["report"].(map[string]any) + _, leaked := projected["success"] + if projected["reportId"] != "report-2" || leaked { + t.Fatalf("latest projection=%s", stdout.String()) + } +} + +func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *testing.T) { + tests := []struct { + declaration shortcut.Shortcut + args []string + }{ + {InboxList, []string{"--start", "2026-07-02T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, + {InboxList, []string{"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00", "--size", "21"}}, + {OutboxList, []string{"--modified-start", "2026-07-01T00:00:00+08:00"}}, + {ReportLatest, []string{"--start", "2026-07-01T00:00:00+08:00"}}, + {ReportLatest, []string{"--start", "2026-07-22T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, + } + for _, test := range tests { + caller := &reportCoverageCaller{responses: map[string][]string{}} + if _, err := runReportCoverage(t, test.declaration, caller, test.args...); err == nil || len(caller.history) != 0 { + t.Errorf("%s args=%v err=%v calls=%v", test.declaration.Command, test.args, err, caller.history) + } + } +} + +func TestCrossPlatformCoverageReportPrimitiveStrictnessAndProjectionBranches(t *testing.T) { + operation := "report/coverage" + if err := reportRequireSuccess(map[string]any{"success": false, "errorMessage": " rejected "}, operation); err == nil { + t.Fatal("remote failure with message returned success") + } + arrayEntries, container, err := reportListCollection(map[string]any{ + "success": true, + "result": []any{map[string]any{"report_id": "report-array"}}, + "hasMore": false, + }, operation) + if err != nil || len(arrayEntries) != 1 || container["hasMore"] != false { + t.Fatalf("array collection entries=%#v container=%#v err=%v", arrayEntries, container, err) + } + if _, err := reportRequiredString(map[string]any{"reportId": "one", "report_id": "two"}, operation, 0, "reportId", "report_id"); err == nil { + t.Fatal("conflicting aliases returned success") + } + if _, err := reportOptionalString(map[string]any{"name": 1}, operation, 0, "name"); err == nil { + t.Fatal("non-string optional value returned success") + } + + integerCases := []struct { + value any + want int64 + ok bool + }{ + {float64(7), 7, true}, {math.NaN(), 0, false}, {math.Inf(1), 0, false}, + {1.5, 0, false}, {int(8), 8, true}, {int64(9), 9, true}, + {json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {"11", 0, false}, + } + for _, test := range integerCases { + got, ok := reportInteger(test.value) + if got != test.want || ok != test.ok { + t.Errorf("reportInteger(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok) + } + } + + fullEntry := map[string]any{ + "success": true, + "result": map[string]any{ + "hasMore": true, + "nextCursor": json.Number("2"), + "report_list": []any{map[string]any{ + "report_id": "report-full", "report_template_name": "Weekly", + "senderName": "sender", "senderUserId": "user", "gmtCreate": int(12), "modifyTime": int64(13), + }}, + }, + } + entries, page, err := reportProjectEntries(fullEntry, operation) + if err != nil || len(entries) != 1 || !page.HasMore || page.Next != "2" || entries[0]["modifiedTime"] != int64(13) { + t.Fatalf("full entry entries=%#v page=%+v err=%v", entries, page, err) + } + if err := reportValidateContinuation(page, 1, operation); err != nil { + t.Fatalf("advancing continuation: %v", err) + } + if err := reportValidateContinuation(reportPageEvidence{}, 0, operation); err != nil { + t.Fatalf("terminal continuation: %v", err) + } + if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "bad"}, 0, operation); err == nil { + t.Fatal("malformed continuation returned success") + } + badOptional := map[string]any{ + "success": true, "hasMore": false, + "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "senderName": 1}}}, + } + if _, _, err := reportProjectEntries(badOptional, operation); err == nil { + t.Fatal("bad optional entry field returned success") + } + + completeDetail := map[string]any{"success": true, "result": map[string]any{ + "reportId": "report-detail", "reportName": "Title", "templateName": "Template", "senderName": "Creator", + "create_time": int(4), "modified_time": json.Number("5"), + "report_content": []any{map[string]any{"key": "field", "value": "", "sort": int(1), "type": int64(2)}}, + }} + detail, err := reportProjectDetail(completeDetail, operation, "report-detail") + if err != nil || detail["modifiedTime"] != int64(5) || len(detail["fields"].([]map[string]any)) != 1 { + t.Fatalf("complete detail=%#v err=%v", detail, err) + } + for name, fixture := range map[string]map[string]any{ + "bad optional string": {"success": true, "result": map[string]any{"reportId": "report-detail", "reportName": 1, "report_content": []any{}}}, + "bad optional integer": {"success": true, "result": map[string]any{"reportId": "report-detail", "create_time": "4", "report_content": []any{}}}, + "missing field value": {"success": true, "result": map[string]any{"reportId": "report-detail", "report_content": []any{map[string]any{"key": "field", "sort": 1, "type": 2}}}}, + } { + if projected, projectErr := reportProjectDetail(fixture, operation, "report-detail"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageReportValidationHelpersCoverAllContracts(t *testing.T) { + if _, err := reportParseISOMillis("start", "not-a-time"); err == nil { + t.Fatal("invalid ISO timestamp returned success") + } + if err := reportValidatePage(-1, 20); err == nil { + t.Fatal("negative cursor returned success") + } + if err := reportValidatePage(0, 0); err == nil { + t.Fatal("zero page size returned success") + } + if err := reportValidatePage(0, 20); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name, start, end string + maximumDays int + }{ + {"bad start", "bad", "2026-07-02T00:00:00+08:00", 20}, + {"bad end", "2026-07-01T00:00:00+08:00", "bad", 20}, + {"too wide", "2026-07-01T00:00:00+08:00", "2026-08-01T00:00:00+08:00", 20}, + } { + if _, _, err := reportValidateRange("start", test.start, "end", test.end, test.maximumDays); err == nil { + t.Errorf("%s returned success", test.name) + } + } + if _, _, err := reportValidateRange("start", "2026-07-01T00:00:00+08:00", "end", "2026-08-01T00:00:00+08:00", 0); err != nil { + t.Fatalf("unbounded valid range: %v", err) + } +} + +func TestCrossPlatformCoverageReportExecutorsSuccessErrorsAndZeroCalls(t *testing.T) { + start := "2026-07-01T00:00:00+08:00" + end := "2026-07-02T00:00:00+08:00" + validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]} ,"hasMore":false}` + + inbox := &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {validList}}} + if _, err := runReportCoverage(t, InboxList, inbox, "--start", start, "--end", end, "--sender-user-ids", "user-1,user-2"); err != nil { + t.Fatal(err) + } + if len(inbox.history) != 1 || len(inbox.history[0].args["senderUserIds"].([]string)) != 2 { + t.Fatalf("inbox args/history=%#v", inbox.history) + } + for _, test := range []struct { + name string + caller *reportCoverageCaller + args []string + }{ + {"call error", &reportCoverageCaller{responses: map[string][]string{}}, []string{"--start", start, "--end", end}}, + {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true}`}}}, []string{"--start", start, "--end", end}}, + {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--start", start, "--end", end, "--cursor", "2"}}, + } { + if _, err := runReportCoverage(t, InboxList, test.caller, test.args...); err == nil { + t.Errorf("inbox %s returned success", test.name) + } + } + directInbox := directReportRuntime(t, InboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end) + if err := InboxList.Execute(directInbox); err == nil { + t.Fatal("direct inbox invalid range returned success") + } + + outboxAll := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-2","createTime":2}]},"hasMore":true,"nextCursor":3}`}}} + if _, err := runReportCoverage(t, OutboxList, outboxAll, + "--cursor", "2", "--start", start, "--end", end, + "--modified-start", start, "--modified-end", end, "--template-name", " Weekly "); err != nil { + t.Fatal(err) + } + if len(outboxAll.history) != 1 || outboxAll.history[0].args["report_template_name"] != "Weekly" || outboxAll.history[0].args["modifiedStartTime"] == nil { + t.Fatalf("outbox args/history=%#v", outboxAll.history) + } + defaultOutbox := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {validList}}} + if _, err := runReportCoverage(t, OutboxList, defaultOutbox); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + caller *reportCoverageCaller + args []string + }{ + {"call error", &reportCoverageCaller{responses: map[string][]string{}}, nil}, + {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true}`}}}, nil}, + {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--cursor", "2"}}, + } { + if _, err := runReportCoverage(t, OutboxList, test.caller, test.args...); err == nil { + t.Errorf("outbox %s returned success", test.name) + } + } + directOutbox := directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end) + if err := OutboxList.Execute(directOutbox); err == nil { + t.Fatal("direct outbox bad creation range returned success") + } + directOutbox = directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, + "--modified-start", "bad", "--modified-end", end) + if err := OutboxList.Execute(directOutbox); err == nil { + t.Fatal("direct outbox bad modified range returned success") + } + + for _, test := range []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {"outbox invalid page", OutboxList, []string{"--size", "0"}}, + {"outbox invalid creation range", OutboxList, []string{"--start", "bad", "--end", end}}, + {"outbox valid modified range", OutboxList, []string{"--modified-start", start, "--modified-end", end}}, + {"template empty query", TemplateSearch, []string{"--query", " "}}, + } { + caller := &reportCoverageCaller{responses: map[string][]string{}} + _, err := runReportCoverage(t, test.declaration, caller, test.args...) + if test.name == "outbox valid modified range" { + if err == nil || len(caller.history) != 1 { + t.Errorf("%s err=%v history=%#v", test.name, err, caller.history) + } + continue + } + if err == nil || len(caller.history) != 0 { + t.Errorf("%s err=%v history=%#v", test.name, err, caller.history) + } + } + + for name, caller := range map[string]*reportCoverageCaller{ + "call error": {responses: map[string][]string{}}, + "projection error": {responses: map[string][]string{"get_available_report_templates": {`{"success":true}`}}}, + } { + if _, err := runReportCoverage(t, TemplateSearch, caller, "--query", "weekly"); err == nil { + t.Errorf("template %s returned success", name) + } + } + directTemplate := directReportRuntime(t, TemplateSearch, &reportCoverageCaller{responses: map[string][]string{}}, "--query", " ") + if err := TemplateSearch.Validate(directTemplate); err == nil { + t.Fatal("direct template whitespace query returned success") + } + legacyTemplate := TemplateSearch + legacyTemplate.OutputRollout = output.RolloutLegacyOnly + legacyTemplateCaller := &reportCoverageCaller{responses: map[string][]string{"get_available_report_templates": {`{"success":true,"items":[]}`}}} + if _, err := runReportCoverage(t, legacyTemplate, legacyTemplateCaller, "--query", "zero"); err != nil { + t.Fatalf("legacy template: %v", err) + } +} + +func TestCrossPlatformCoverageReportLatestExecutorRemainingBranches(t *testing.T) { + validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false}` + validDetail := `{"success":true,"result":{"report_Id":"report-1","report_content":[]}}` + defaultCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {validList}, "get_report_entry_details": {validDetail}, + }} + if _, err := runReportCoverage(t, ReportLatest, defaultCaller, "--keyword", " Weekly "); err != nil { + t.Fatal(err) + } + if defaultCaller.history[0].args["report_template_name"] != "Weekly" { + t.Fatalf("latest keyword args=%#v", defaultCaller.history[0].args) + } + + for name, caller := range map[string]*reportCoverageCaller{ + "list call error": {responses: map[string][]string{}}, + "list projection error": {responses: map[string][]string{ + "get_send_report_list": {`{"success":true}`}, + }}, + "empty candidates": {responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[]},"hasMore":false}`}, + }}, + "detail call error": {responses: map[string][]string{ + "get_send_report_list": {validList}, + }}, + "detail projection error": {responses: map[string][]string{ + "get_send_report_list": {validList}, "get_report_entry_details": {`{"success":true}`}, + }}, + } { + if _, err := runReportCoverage(t, ReportLatest, caller); err == nil { + t.Errorf("latest %s returned success", name) + } + } + directLatest := directReportRuntime(t, ReportLatest, &reportCoverageCaller{responses: map[string][]string{}}, + "--start", "bad", "--end", "2026-07-02T00:00:00+08:00") + if err := ReportLatest.Execute(directLatest); err == nil { + t.Fatal("direct latest invalid range returned success") + } + if _, err := reportLatestEntryID([]map[string]any{{"createTime": int64(1), "reportId": 1}}, "report/latest"); err == nil { + t.Fatal("non-string latest report identity returned success") + } +} + +func TestCrossPlatformCoverageReportOutputLegacyAndInvalidPagination(t *testing.T) { + command := &cobra.Command{Use: "report-page"} + command.SetOut(io.Discard) + command.SetContext(context.Background()) + declaration := shortcut.Shortcut{Service: "report", Product: "report"} + rt := shortcut.RuntimeContextForTest(command, declaration) + output.SetCommandRollout(command, output.RolloutLegacyOnly) + if err := outputReportPage(rt, "reports", []map[string]any{{"reportId": "report-1"}}, reportPageEvidence{HasMore: true, Next: "2"}); err != nil { + t.Fatalf("legacy report page: %v", err) + } + + ctx, _ := output.WithResultStore(context.Background()) + command.SetContext(ctx) + output.SetCommandRollout(command, output.RolloutUnifiedActive) + if err := outputReportPage(rt, "reports", nil, reportPageEvidence{HasMore: true}); err == nil { + t.Fatal("unified page without continuation returned success") + } +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 12a00442..60aa42c6 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -40,6 +40,15 @@ var attendanceSemanticCatalogJSON []byte //go:embed semantic_catalog_mail.json var mailSemanticCatalogJSON []byte +//go:embed semantic_catalog_oa.json +var oaSemanticCatalogJSON []byte + +//go:embed semantic_catalog_ding.json +var dingSemanticCatalogJSON []byte + +//go:embed semantic_catalog_report.json +var reportSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -69,6 +78,9 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( todoSemanticCatalogJSON, attendanceSemanticCatalogJSON, mailSemanticCatalogJSON, + oaSemanticCatalogJSON, + dingSemanticCatalogJSON, + reportSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { @@ -130,11 +142,12 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) { command, record.Availability)) } // A command that was already part of the visible CLI contract cannot be - // hidden in the same feature change merely because Agent publication is - // withdrawn. This narrow fact preserves historical discovery/argv while - // the unavailable Interface and public=false keep it out of Agent routes. - if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) { - panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command)) + // hidden merely because Agent publication is withdrawn. Compatibility + // visibility owns only historical CLI discovery; availability independently + // records whether that compatibility path still executes. public=false keeps + // both available and unavailable compatibility leaves out of Agent routes. + if record.CompatibilityVisible && record.Public { + panic(fmt.Sprintf("semantic catalog command %q cannot be both public and compatibility-visible", command)) } key := publicCatalogKey(source.Service, command) if _, exists := out[key]; exists { diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go index d555aced..df822179 100644 --- a/internal/shortcut/semantic_catalog_coverage_test.go +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -136,21 +136,6 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) } } }`, - "compatibility-visible available": `{ - "version": 1, - "service": "chat", - "default_availability": "available", - "shortcuts": { - "+messages": { - "disposition": "semantic_adapter", - "semantic_delta": "reviewed", - "risk": "read", - "public": false, - "compatibility_visible": true, - "reviewed": true - } - } - }`, } original := semanticCatalogJSON t.Cleanup(func() { semanticCatalogJSON = original }) @@ -210,6 +195,28 @@ func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) { if InPublicCatalog(item.Service, item.Command) { t.Fatal("compatibility-visible unavailable shortcut entered the public catalog") } + + available := map[string]semanticCatalogRecord{} + loadSemanticCatalog([]byte(`{ + "version":1, + "service":"compatibility-test", + "default_availability":"unavailable", + "shortcuts":{ + "+legacy":{ + "disposition":"semantic_adapter", + "semantic_delta":"historical CLI path remains executable but is not Agent-public", + "risk":"write", + "availability":"available", + "public":false, + "compatibility_visible":true, + "reviewed":true + } + } + }`), available) + record := available[publicCatalogKey("compatibility-test", "+legacy")] + if record.Public || !record.CompatibilityVisible || record.Availability != AvailabilityAvailable { + t.Fatalf("available compatibility record = %#v", record) + } } func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) { diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json new file mode 100644 index 00000000..7c1565d5 --- /dev/null +++ b/internal/shortcut/semantic_catalog_ding.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "service": "ding", + "default_availability": "unavailable", + "shortcuts": { + "+list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.dingMessages、稳定 openDingId 与 hasMore/nextCursor;exact Shortcut 已完成已知非空与保证零项证明,但真实续页返回停滞 cursor,严格实现拒绝虚假分页并保持不可用。", + "risk": "read", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+receiver-status": { + "disposition": "semantic_adapter", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+send-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行,并修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;因接收项没有稳定接收人身份且撤回无可查询终态,不进入 Agent 公共发现。", + "risk": "write", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+send-by-message": { + "disposition": "semantic_adapter", + "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING并映射 lark-cli 三种 urgent 任务;隔离 APP fixture 已证明源消息、稳定 DING 回执和目标 DING 读回,但接收人身份与撤回终态仍不可验证,保持不可用。", + "risk": "write", + "public": false, + "reviewed": true + }, + "+recall-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行;个人 DING 撤回只返回布尔成功且不回显终态,不能作为 Agent 可验证写能力,因此不进入公共发现。", + "risk": "high-risk-write", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json new file mode 100644 index 00000000..0aee7c46 --- /dev/null +++ b/internal/shortcut/semantic_catalog_oa.json @@ -0,0 +1,96 @@ +{ + "version": 1, + "service": "oa", + "default_availability": "unavailable", + "shortcuts": { + "+list-executed": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝,因此不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+list-submitted": { + "disposition": "semantic_adapter", + "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,因此不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+my-initiated": { + "disposition": "alias_internal", + "semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持非公开。", + "risk": "read", + "availability": "available", + "primary": "+list-submitted", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+search-forms": { + "disposition": "semantic_adapter", + "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+done-approvals": { + "disposition": "alias_internal", + "semantic_delta": "保留旧的首屏已处理审批摘要;已验证严格非空结果,但不提供页码,新的 Agent 路由统一使用 +list-executed。", + "risk": "read", + "availability": "available", + "primary": "+list-executed", + "public": false, + "reviewed": true + }, + "+list-forms": { + "disposition": "semantic_adapter", + "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失、畸形、停滞或倒退游标,下游修复前不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+list-pending": { + "disposition": "semantic_adapter", + "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture,因此不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+pending": { + "disposition": "alias_internal", + "semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均保持非公开。", + "risk": "read", + "availability": "available", + "primary": "+list-pending", + "public": false, + "reviewed": true + }, + "+list-cc": { + "disposition": "semantic_adapter", + "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture,因此不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+approve-by": { + "disposition": "primary_smart", + "semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,因此不进入 Agent 公开发现。", + "risk": "high-risk-write", + "availability": "available", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_report.json b/internal/shortcut/semantic_catalog_report.json new file mode 100644 index 00000000..59f11983 --- /dev/null +++ b/internal/shortcut/semantic_catalog_report.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "service": "report", + "default_availability": "unavailable", + "shortcuts": { + "+inbox-list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+outbox-list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+template-search": { + "disposition": "semantic_adapter", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+report-latest": { + "disposition": "primary_smart", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + } + } +} diff --git a/internal/shortcut/smart/approve.go b/internal/shortcut/smart/approve.go deleted file mode 100644 index 9ad9a36b..00000000 --- a/internal/shortcut/smart/approve.go +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "fmt" - "strconv" - "strings" - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// Approve: agree to ONE of MY pending OA approvals by matching a keyword in its -// title / order number, in a single command (钉钉原生编排能力). -// -// Steps (all tool names + params copied verbatim from helpers/oa.go): -// -// 1. list my pending approvals via list_pending_approvals (starTime / endTime -// as float64 millis over a recent window, plus query=keyword for -// server-side narrowing — mirroring `dws oa approval list-pending`); -// -// 2. scan the returned instances and keep the ones whose title / order number -// contains --keyword: none → "没找到待审批单据", many → list candidates -// (标题 + processInstanceId) so the caller can be more specific, exactly one -// → take its processInstanceId; -// -// 3. resolve the pending taskId for that instance via list_pending_tasks -// (processInstanceId), then agree via approve_processInstance -// (processInstanceId + taskId as float64, optional remark) — -// mirroring `dws oa approval tasks` + `dws oa approval approve`. -// -// dws oa +approve-by --keyword 报销 -// dws oa +approve-by --keyword 出差单 --comment "同意" -var Approve = shortcut.Shortcut{ - Service: "oa", - Command: "+approve-by", - Product: "oa", - Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", - Intent: "当你只记得某张待你审批单据的标题或单号关键词、想直接把它审批通过,却不想先翻待办列表、" + - "复制审批实例 ID 再查任务 ID 时使用;内部先拉取你近三个月待处理的审批单,按标题/单号包含关键词匹配:" + - "没匹配到会提示「没找到待审批单据」,匹配到多条会列出候选(标题+实例ID)让你写得更精确," + - "唯一命中时先取出它的待审批任务 ID,再以「同意」提交。这会真实提交审批决策且不可撤回,请确认关键词足够精确。", - Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, - {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +approve-by --keyword 报销`, - `dws oa +approve-by --keyword 出差单 --comment "同意"`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - keyword := strings.TrimSpace(rt.Str("keyword")) - if keyword == "" { - return apperrors.NewValidation("请用 --keyword 提供待审批单据的单号或标题关键词") - } - - // Step 1 — list my pending approvals. starTime/endTime are float64 - // milliseconds and query narrows server-side, mirroring - // helpers.list-pending (list_pending_approvals). Window: last ~90 days. - now := time.Now() - listArgs := map[string]any{ - "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), - "endTime": float64(now.UnixMilli()), - "query": keyword, - } - data, err := rt.CallMCPData("oa", "list_pending_approvals", listArgs) - if err != nil { - return err - } - - // Step 2 — match by title / order number substring. - matches := shortcutApproveMatch(data, keyword) - switch { - case len(matches) == 0: - return apperrors.NewValidation(fmt.Sprintf( - "没找到待审批单据:待我处理的审批里没有标题/单号包含 %q 的单据。", keyword)) - case len(matches) > 1: - return apperrors.NewValidation(fmt.Sprintf( - "%q 匹配到 %d 条待审批单据,请用更精确的关键词,或用 `dws oa approval approve --instance-id --task-id` 指定:%s", - keyword, len(matches), strings.Join(shortcutApproveLabels(matches), ";"))) - } - - instanceID := matches[0].instanceID - - // Step 3a — resolve the pending taskId for this instance via - // list_pending_tasks (processInstanceId), mirroring `dws oa approval tasks`. - tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{ - "processInstanceId": instanceID, - }) - if err != nil { - return err - } - taskID := shortcutApproveTaskID(tasksData) - if taskID == "" { - return apperrors.NewValidation(fmt.Sprintf( - "没能拿到 %q 的待审批任务 ID,请用 `dws oa approval tasks --instance-id %s` 查看后手动 approve。", - matches[0].title, instanceID)) - } - - // Step 3b — agree. taskId is passed as float64 and remark is optional, - // mirroring helpers.approve (approve_processInstance). - taskIDNum, err := strconv.ParseFloat(taskID, 64) - if err != nil { - return apperrors.NewValidation(fmt.Sprintf("任务 ID %q 不是合法数字,无法审批。", taskID)) - } - approveArgs := map[string]any{ - "processInstanceId": instanceID, - "taskId": taskIDNum, - } - if c := strings.TrimSpace(rt.Str("comment")); c != "" { - approveArgs["remark"] = c - } - return rt.CallMCP("approve_processInstance", approveArgs) - }, -} - -// shortcutApproveInstance is the minimal identity we need from a pending-approval -// entry: its processInstanceId (fed to approve/tasks) and a human title/order -// number for matching + disambiguation. -type shortcutApproveInstance struct { - instanceID string - title string -} - -// shortcutApproveMatch walks a list_pending_approvals response and returns the -// instances whose title / order number contains keyword (case-insensitive). The -// gateway wraps the list under one of several common container keys, so we probe -// them defensively (mirroring latestMinutesItems / shortcutTodoCards). -func shortcutApproveMatch(data map[string]any, keyword string) []shortcutApproveInstance { - items := shortcutApproveItems(data) - kw := strings.ToLower(keyword) - var out []shortcutApproveInstance - seen := map[string]bool{} - for _, m := range items { - id := shortcutApproveInstanceID(m) - if id == "" || seen[id] { - continue - } - title := shortcutApproveTitle(m) - // Match on either the visible title/order number or the instance id. - if !strings.Contains(strings.ToLower(title), kw) && - !strings.Contains(strings.ToLower(id), kw) { - continue - } - seen[id] = true - if title == "" { - title = id - } - out = append(out, shortcutApproveInstance{instanceID: id, title: title}) - } - return out -} - -// shortcutApproveItems pulls the list of pending-approval entries out of the -// response, probing common container shapes before scanning for a bare list. -func shortcutApproveItems(data map[string]any) []map[string]any { - for _, key := range []string{"result", "list", "instances", "items", "data", "records", "processList"} { - if arr, ok := data[key].([]any); ok { - return shortcutApproveToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "instances", "items", "records", "result", "processList"} { - if arr, ok := inner[k2].([]any); ok { - return shortcutApproveToMaps(arr) - } - } - } - } - return nil -} - -func shortcutApproveToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// shortcutApproveInstanceID reads an entry's processInstanceId (the value the -// helper feeds to --instance-id), tolerating a few common field names and both -// string / numeric JSON encodings. -func shortcutApproveInstanceID(m map[string]any) string { - for _, key := range []string{"processInstanceId", "processInstanceID", "instanceId", "businessId"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveTitle reads a human-facing title / order number for matching -// and disambiguation, probing common field names. -func shortcutApproveTitle(m map[string]any) string { - for _, key := range []string{"title", "processTitle", "name", "subject", "businessId", "processCode"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveTaskID walks a list_pending_tasks response and returns the -// first pending taskId (the value the helper feeds to --task-id). -func shortcutApproveTaskID(data map[string]any) string { - items := shortcutApproveItems(data) - // list_pending_tasks may also return a bare {"result": [...]}/task list; the - // probe above covers those. Some gateways return a single task object. - if len(items) == 0 { - if id := shortcutApproveTaskIDField(data); id != "" { - return id - } - if r, ok := data["result"].(map[string]any); ok { - if id := shortcutApproveTaskIDField(r); id != "" { - return id - } - } - return "" - } - for _, m := range items { - if id := shortcutApproveTaskIDField(m); id != "" { - return id - } - } - return "" -} - -func shortcutApproveTaskIDField(m map[string]any) string { - for _, key := range []string{"taskId", "taskID", "activityId"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveStr coerces a JSON value into a string, tolerating string and -// numeric encodings (taskId / instance id can arrive as either). -func shortcutApproveStr(v any) string { - switch t := v.(type) { - case string: - return t - case float64: - return strconv.FormatFloat(t, 'f', -1, 64) - case int64: - return strconv.FormatInt(t, 10) - } - return "" -} - -func shortcutApproveLabels(items []shortcutApproveInstance) []string { - out := make([]string, 0, len(items)) - for _, c := range items { - out = append(out, fmt.Sprintf("%s(instanceId=%s)", c.title, c.instanceID)) - } - return out -} - -func init() { - shortcut.Register(Approve) -} diff --git a/internal/shortcut/smart/done_approvals.go b/internal/shortcut/smart/done_approvals.go deleted file mode 100644 index f75b51fe..00000000 --- a/internal/shortcut/smart/done_approvals.go +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// DoneApprovals: READ-ONLY list of the approval tasks I have already processed -// (my approval history), projected to clean fields. Unlike `oa +approve-by` -// (which agrees to an approval) or `oa +pending` (which lists what still waits -// for me), this shortcut only looks back at what I've already handled — it never -// approves / rejects / mutates anything. -// -// Steps (tool name + param keys copied verbatim from helpers/oa.go): -// -// 1. list my done approvals via get_done_tasks with pageNumber / pageSize as -// float64, mirroring `dws oa approval list-executed`; optional --limit maps -// to pageSize (float64). This tool takes NO time window (unlike -// list_pending_approvals) — only pageNumber/pageSize/query. -// -// 2. defensively unwrap the returned instance list (reusing shortcutApproveItems -// from approve.go, multiple candidate container keys + one nested level) and -// project each entry to a readable shape {title, originatorName, -// processInstanceId, createTime}, every field probed across candidate keys. -// -// 3. if nothing has been processed, report "没有已处理的审批记录" instead of an -// empty raw dump. -// -// dws oa +done-approvals -// dws oa +done-approvals --limit 10 -var DoneApprovals = shortcut.Shortcut{ - Service: "oa", - Command: "+done-approvals", - Product: "oa", - Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", - Intent: "当你只想快速回看「我已经处理过(同意/拒绝)」的审批任务历史——每条的标题、发起人、审批实例 ID 和创建时间——" + - "而不想拿到一大坨原始字段时使用;内部拉取你的已办审批单,再在本地投影出可读字段。" + - "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改任何审批;" + - "若没有任何已处理记录则提示「没有已处理的审批记录」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +done-approvals`, - `dws oa +done-approvals --limit 10`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my done approvals. pageNumber/pageSize are float64. Keys - // copied verbatim from helpers.list-executed (get_done_tasks). This tool - // carries no time window; pageNumber defaults to the first page. - // pageSize is required by get_done_tasks — the 1:1 list-executed always - // sends one (a missing/zero pageSize triggers a backend business error), - // so default to 20 and let --limit override. - params := map[string]any{ - "pageNumber": float64(1), - "pageSize": float64(20), - } - if rt.Changed("limit") { - if n := rt.Int("limit"); n > 0 { - params["pageSize"] = float64(n) - } - } - data, err := rt.CallMCPData("oa", "get_done_tasks", params) - if err != nil { - return err - } - - // Step 2 — project entries. shortcutApproveItems (approve.go) defensively - // unwraps the list container (result/list/instances/items/data/records/ - // processList, incl. one nested level). - items := shortcutApproveItems(data) - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - // shortcutApproveTitle probes title/processTitle/name/subject/… - "title": shortcutApproveTitle(m), - // shortcutApproveInstanceID probes processInstanceId/instanceId/… - "processInstanceId": shortcutApproveInstanceID(m), - "originatorName": pendingApprovalsOriginator(m), - "createTime": pendingApprovalsCreateTime(m), - }) - } - - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("没有已处理的审批记录") - } - - return rt.Output(map[string]any{"count": len(results), "done": results}) - }, -} - -func init() { - shortcut.Register(DoneApprovals) -} diff --git a/internal/shortcut/smart/my_initiated.go b/internal/shortcut/smart/my_initiated.go deleted file mode 100644 index 195fb6a9..00000000 --- a/internal/shortcut/smart/my_initiated.go +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "strconv" - "strings" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// MyInitiated: list the OA approval instances *I* have initiated (submitted), in -// one step. -// -// Steps: -// -// 1. call get_submitted_instances on the OA server — the exact tool + parameter -// names (pageNumber / pageSize as float64, optional query) used by -// helpers.approvalSubmittedListCmd ("dws oa approval list-submitted"). page -// and limit default to the same first-page values (page 1, limit 20). -// -// 2. defensively locate the instance list inside the response (probing common -// container keys, incl. a nested result/data object) and project each item -// down to {title, businessId, status, processInstanceId} with multiple -// candidate keys per field. When no recognisable list is found we print the -// raw payload so nothing is silently lost. -// -// 3. print via rt.Output so it honours --format / --jq / --fields. -// -// Read-only: it only lists and reshapes my submitted approvals, it never -// approves, rejects, revokes or mutates anything. -// -// dws oa +my-initiated -// dws oa +my-initiated --query 报销 -// dws oa +my-initiated --page 2 --limit 50 -var MyInitiated = shortcut.Shortcut{ - Service: "oa", - Command: "+my-initiated", - Product: "oa", - Description: "列出我发起(提交)的审批单据", - Intent: "当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;" + - "内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted)," + - "再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。" + - "可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。" + - "这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_my_initiated", - CanonicalPath: "oa.shortcut_my_initiated", - CLIPath: "oa +my-initiated", - PrimaryCLIPath: "oa +my-initiated", - }, - Description: "列出我发起(提交)的审批单据", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "列出我发起(提交)的审批单据", - UseWhen: []string{"当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted),再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{ - "dws oa +my-initiated", - "dws oa +my-initiated --query 报销", - }, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)", Required: false}, - {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1", Required: false}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20", Required: false}, - }, - Tips: []string{ - `dws oa +my-initiated`, - `dws oa +my-initiated --query 报销`, - `dws oa +my-initiated --page 2 --limit 50`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my submitted approval instances. pageNumber/pageSize are - // float64 and query is optional, mirroring helpers.approvalSubmittedListCmd. - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 - } - params := map[string]any{ - "pageNumber": float64(page), - "pageSize": float64(limit), - } - if q := strings.TrimSpace(rt.Str("query")); q != "" { - params["query"] = q - } - - data, err := rt.CallMCPData("oa", "get_submitted_instances", params) - if err != nil { - return err - } - - // Step 2 — project the instance list; fall back to the raw payload when we - // cannot locate a recognisable list. - items := myInitiatedItems(data) - if len(items) == 0 { - return rt.Output(data) - } - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - "title": myInitiatedTitle(m), - "businessId": myInitiatedBusinessID(m), - "status": myInitiatedStatus(m), - "processInstanceId": myInitiatedInstanceID(m), - }) - } - - // Step 3 — print the projected list. - return rt.Output(map[string]any{"initiated": results}) - }, -} - -// myInitiatedItems locates the instance list inside a get_submitted_instances -// response, probing common container keys at the top level and nested under a -// result/data object. Returns nil when no list is found. -func myInitiatedItems(data map[string]any) []map[string]any { - if data == nil { - return nil - } - keys := []string{"list", "instances", "processInstances", "items", "data", "records", "result", "rows"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return myInitiatedToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "instances", "processInstances", "items", "data", "records", "rows"} { - if arr, ok := inner[k2].([]any); ok { - return myInitiatedToMaps(arr) - } - } - } - } - return nil -} - -func myInitiatedToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// myInitiatedTitle reads an instance's human-readable title, tolerating the -// common title keys the gateway may use. -func myInitiatedTitle(m map[string]any) any { - for _, key := range []string{"title", "subject", "name", "processName", "formName", "instanceTitle"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedBusinessID reads an instance's business / order number, preferring -// the customer-visible business id and falling back to related identifiers. -func myInitiatedBusinessID(m map[string]any) any { - for _, key := range []string{"businessId", "bizId", "orderNo", "orderNumber", "number", "serialNumber"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedStatus reads an instance's current status/result, tolerating both -// the running status and the final result keys. -func myInitiatedStatus(m map[string]any) any { - for _, key := range []string{"status", "statusText", "processStatus", "instanceStatus", "result", "approvalResult"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedInstanceID reads an instance's processInstanceId, tolerating the -// common id keys. -func myInitiatedInstanceID(m map[string]any) any { - for _, key := range []string{"processInstanceId", "instanceId", "processInstanceID", "id", "bizId"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedString coerces a scalar JSON value to a trimmed string, returning -// "" for nil / non-scalar / empty values. -func myInitiatedString(v any) string { - switch typed := v.(type) { - case string: - return strings.TrimSpace(typed) - case float64: - // approval ids/numbers may arrive as JSON numbers; render without a - // trailing decimal point when integral. - return strconv.FormatFloat(typed, 'f', -1, 64) - case int64: - return strconv.FormatInt(typed, 10) - case int: - return strconv.Itoa(typed) - case bool: - if typed { - return "true" - } - return "false" - default: - return "" - } -} - -func init() { - shortcut.Register(MyInitiated) -} diff --git a/internal/shortcut/smart/pending_approvals.go b/internal/shortcut/smart/pending_approvals.go deleted file mode 100644 index 3c4e34bd..00000000 --- a/internal/shortcut/smart/pending_approvals.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "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) -} diff --git a/internal/shortcut/smart/report_latest.go b/internal/shortcut/smart/report_latest.go deleted file mode 100644 index c756573a..00000000 --- a/internal/shortcut/smart/report_latest.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "strconv" - "strings" - "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) -} diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 9d9f5836..602baf39 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -197,7 +197,8 @@ 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` and must remain unavailable. + // shown only by `dws shortcut list --all`; Availability independently says + // whether the historical execution path remains callable. CompatibilityVisible bool // Disposition is the reviewed semantic relation to the Runtime Schema leaf // surface. It determines default Agent discovery independently from live diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 5c1e6546..de3a1f2d 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -29,6 +29,9 @@ 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", ] diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index c1474dd0..e00cf936 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -71,13 +71,29 @@ "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." @@ -86,13 +102,29 @@ "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." diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index d695e969..3d226752 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -49,13 +49,13 @@ cli_version: ">=1.0.15" | `chat` | 98 | `dingtalk-chat` | | `contact` | 14 | `dingtalk-contact` | | `devapp` | 19 | `dingtalk-misc` | -| `ding` | 4 | `dingtalk-misc` | +| `ding` | 1 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | | `mail` | 8 | `dingtalk-mail` | | `minutes` | 27 | `dingtalk-minutes` | -| `oa` | 7 | `dingtalk-misc` | -| `report` | 2 | `dingtalk-misc` | +| `oa` | 1 | `dingtalk-misc` | +| `report` | 4 | `dingtalk-misc` | | `sheet` | 2 | `dingtalk-misc` | | `todo` | 21 | `dingtalk-todo` | | `wiki` | 20 | `dingtalk-wiki` | diff --git a/skills/multi/dingtalk-misc/references/ding.md b/skills/multi/dingtalk-misc/references/ding.md index 5cce1ce6..d2485596 100644 --- a/skills/multi/dingtalk-misc/references/ding.md +++ b/skills/multi/dingtalk-misc/references/ding.md @@ -173,10 +173,7 @@ dws ding message recall-personal --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 给指定人 | ## 意图表 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index ee145ce1..4433beaa 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -990,12 +990,6 @@ 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 | 按关键字模糊搜索当前用户可见的审批表单 | diff --git a/skills/multi/dingtalk-misc/references/report.md b/skills/multi/dingtalk-misc/references/report.md index 9ff940e9..cf73d9d5 100644 --- a/skills/multi/dingtalk-misc/references/report.md +++ b/skills/multi/dingtalk-misc/references/report.md @@ -449,8 +449,10 @@ dws report outbox list --cursor 0 --size 20 --format json | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws report +inbox-list` | read | 列出我收到的日报(按时间范围分页) | -| `dws report +outbox-list` | read | 列出我发出的日报(可选时间/模版名过滤) | +| `dws report +inbox-list` | read | 列出我收到的日志 | +| `dws report +outbox-list` | read | 列出我发出的日志 | +| `dws report +report-latest` | read | 读取我最近提交的一篇日志详情 | +| `dws report +template-search` | read | 按名称搜索可用日志模板 | ## 意图表