From 4da5a07d1dfdb9cf41ab71c2da37e1a87b981f2c Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 16:18:26 +0800 Subject: [PATCH] feat(shortcut): harden DING reads and availability --- docs/shortcut-downstream-requirements/ding.md | 78 +++++ docs/shortcut-public-catalog.json | 28 +- internal/app/schema_shortcut_contract_test.go | 2 +- .../builtin/ding_semantic_catalog_test.go | 83 +++++ internal/shortcut/ding/common.go | 315 ++++++++++++++++++ internal/shortcut/ding/ding.go | 303 +++++++---------- ...ing_strict_cross_platform_coverage_test.go | 231 +++++++++++++ internal/shortcut/public_catalog_generated.go | 3 - internal/shortcut/semantic_catalog.go | 4 + internal/shortcut/semantic_catalog_ding.json | 43 +++ scripts/gen_shortcut_public_catalog.py | 1 + 11 files changed, 880 insertions(+), 211 deletions(-) create mode 100644 docs/shortcut-downstream-requirements/ding.md create mode 100644 internal/shortcut/builtin/ding_semantic_catalog_test.go create mode 100644 internal/shortcut/ding/common.go create mode 100644 internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/semantic_catalog_ding.json diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md new file mode 100644 index 00000000..0d35fa99 --- /dev/null +++ b/docs/shortcut-downstream-requirements/ding.md @@ -0,0 +1,78 @@ +# DING Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> DWS 基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866` +> 对比基线:lark-cli 1.0.87 +> 范围:Shortcut-only DING surface + +## 1. 执行摘要 + +- 源码 Shortcut:5;公开:1;unavailable:4。 +- 读取严格化:PASS。`+list` 与 `+receiver-status` 只接受显式布尔成功、准确结果路径、正确集合类型和合法元素。 +- 分页真实性:PASS(拒绝虚假成功)。`+list` 仅在 `hasMore` 与数值 `nextCursor` 一致且游标前进时发布续页信息;真实续页返回停滞游标,严格实现返回 `stalled_cursor`,因此该能力保持 unavailable。 +- 真实读取:PASS。`+list` 已执行已知非空页和保证零项页;`+receiver-status` 已对列表中稳定身份执行精确非空查询。 +- 写安全:PASS。3 个写 Shortcut 均需确认,确认前 0 次下游调用;因缺少可逆 fixture、精确接收人读回和零残留清理,确认后也由本地能力门禁停止。 +- 脱敏:PASS。本文仅保留聚合数量与 PASS 标签,不保存 ID、标题、人名、正文、原始响应或请求追踪信息。 + +## 2. 用户任务与 Lark 映射 + +| 用户任务 | DWS Shortcut | lark-cli 1.0.87 | 状态 | +|---|---|---|---| +| 查询 DING 历史并取得稳定身份 | `ding +list` | 无直接任务 | unavailable;真实续页游标停滞 | +| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | PASS / public;平台身份模型不同 | +| 基于已有消息发送应用内加急 | `ding +send-by-message --type app` | `im messages urgent_app` | unavailable | +| 基于已有消息发送短信加急 | `ding +send-by-message --type sms` | `im messages urgent_sms` | unavailable | +| 基于已有消息发送电话加急 | `ding +send-by-message --type call` | `im messages urgent_phone` | unavailable | +| 以用户身份新建 DING | `ding +send-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | +| 撤回用户身份 DING | `ding +recall-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | + +## 3. 真实 E2E 聚合矩阵 + +| Exact Shortcut | 已知非空 | 保证零项 | 身份/分页 | 写后读回 | 清理 | 结论 | +|---|---:|---:|---|---|---|---| +| `+list` | PASS(50) | PASS(0) | PASS(严格拒绝);续页游标停滞 | 不适用 | 不适用 | unavailable | +| `+receiver-status` | PASS(1) | 不适用:精确 ID 查询 | PASS:全部返回项身份匹配 | 不适用 | 不适用 | public | +| `+send-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | +| `+send-by-message` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | +| `+recall-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | + +## 4. 下游需求 + +### DS-DING-001 — 稳定写回执与接收人身份 + +- 优先级:P1;类型:contract insufficient。 +- 影响:`+send-personal`、`+send-by-message`。 +- 需要明确布尔业务成功、稳定 `openDingId`、逐接收人稳定身份与受理状态;集合必须始终存在且类型固定,不能用空响应或仅 HTTP 成功表示业务成功。 +- 写后读取必须能按同一 `openDingId` 返回稳定接收人身份,以精确比对请求集合,而不是仅返回显示名。 +- 幂等键重复提交必须返回同一资源或稳定冲突语义,并标明是否已开始执行与是否可安全重试。 +- 验收:确认前 0 调用;确认后写入一次;按稳定身份精确读回全部接收人;清理后再次读取证明无残留。 + +### DS-DING-002 — 可验证撤回终态 + +- 优先级:P1;类型:missing capability。 +- 影响:`+recall-personal` 及两种发送能力的清理闭环。 +- 撤回应返回稳定目标身份与明确终态,或提供按该身份查询撤回状态的只读接口。 +- `success=true` 单一回执不足以证明接收端通知已撤回;需要可查询的 terminal 状态,并区分未开始、已完成、部分撤回与未知效果。 +- 验收:创建专用可回滚 fixture;精确撤回;按同一身份读回终态;重复撤回具有稳定幂等语义;无通知残留。 + +### DS-DING-003 — 游标边界语义 + +- 优先级:P2;类型:contract insufficient。 +- 影响:`+list` 的跨页聚合调用方。 +- 当前真实续页返回的 `nextCursor` 未严格前进,且基线相邻页还观察到 1 个稳定身份边界重复项。下游需声明 `nextCursor` 是包含式还是排除式边界,并保证续页凭据可以推进。 +- 验收:连续两页均返回合法分页事实;游标严格前进;跨页无重复,或合同明确要求调用方按稳定身份去重;终页明确 `hasMore=false` 且无虚假续页凭据。 + +## 5. Fixture、权限与清理 + +| 需求 | 安全资源模型 | 到期/回收 | 状态 | +|---|---|---|---| +| 应用内 DING 写 fixture | 专用测试接收人和无业务正文 | 用后立即撤回并读回终态 | BLOCKED | +| 短信/电话 DING fixture | 隔离租户、显式成本授权、专用号码 | 用后核对计费与通知残留 | BLOCKED | +| 消息转 DING fixture | 专用测试会话与可删除源消息 | 撤回 DING 后删除源消息 | BLOCKED | +| 接收状态读取 fixture | 由公开列表取得稳定 DING 身份 | 只读,无残留 | PASS | + +## 6. 安全与脱敏声明 + +- PASS:不含用户、组织、租户、profile、消息、会话或 DING 的真实 ID。 +- PASS:不含标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。 +- PASS:真实证据仅记录命令级标签和聚合计数;未将本地诊断响应提交到仓库。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 8499b8bc..3df4be3e 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -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 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "availability": "available" }, { "suite": "semantic", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 82f982fb..68a1db5e 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -18,7 +18,7 @@ import ( const ( publicShortcutCount = 422 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, - // including the hidden historical minutes.shortcut_minutes_search contract. + // including reviewed hidden compatibility and unavailable contracts. schemaPublishedShortcutCount = 447 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. publiclyDeliveredShortcutCount = 422 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..f944093d --- /dev/null +++ b/internal/shortcut/builtin/ding_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 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)) + } + 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("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 !item.Hidden || shortcut.InPublicCatalog("ding", 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 != 1 || unavailable != 4 { + t.Fatalf("public/unavailable=%d/%d, want 1/4", public, unavailable) + } +} diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go new file mode 100644 index 00000000..719b15eb --- /dev/null +++ b/internal/shortcut/ding/common.go @@ -0,0 +1,315 @@ +// 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." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until a dedicated reversible fixture proves confirmation-before-call, a stable write receipt, exact receiver/readback identity, and cleanup without residual notifications." +) + +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 尚无可逆真实 fixture、精确接收人读回与零残留清理证据,当前不可执行", + 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..1698cdde 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -1,226 +1,161 @@ // 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" + "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 类型", Enum: []string{"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: "openDingId"}}, + "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 = unavailableDingWrite( + "+send-personal", "以本人身份发送 DING 给指定人", + "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供精确回执、接收人读回和可验证清理前不可执行。", + 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: "提醒方式", Enum: []string{"app", "sms", "call"}}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "receiverOpenDingTalkIds": rt.StrSlice("users"), - "content": rt.Str("content"), - "remindType": rt.Str("type"), - } - if rt.Changed("uuid") { - params["uuid"] = rt.Str("uuid") - } - return rt.CallMCP("send_personal_ding", params) + []contract.ParamDecl{ + {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"}, + {Name: "content", Property: "content"}, {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"}, }, -} + "dws ding +send-personal --users --content ", +) -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 任务,但安全 fixture 与精确清理闭环未具备。", + 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 = 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", +var RecallPersonal = unavailableDingWrite( + "+recall-personal", "撤回本人发起的 DING", + "需要撤回本人发出的 DING 时使用;必须先有稳定写回执,并能精确证明目标已撤回且没有残留通知,当前尚不满足。", + shortcut.RiskHighWrite, true, + []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, + []contract.ParamDecl{{Name: "id", Property: "openDingId"}}, + "dws ding +recall-personal --id ", +) + +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 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..b6fba7eb --- /dev/null +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -0,0 +1,231 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package ding + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "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 + history []string +} + +func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + 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 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, SendByMessage, RecallPersonal} { + if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s write safety/interface drift", declaration.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, "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) + } + } +} + +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"}}}, + "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 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) { + cases := []struct { + declaration shortcut.Shortcut + args []string + }{ + {SendPersonal, []string{"--users", "D-fixture", "--content", "fixture"}}, + {SendByMessage, []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"}}, + {RecallPersonal, []string{"--id", "ding-fixture"}}, + } + for _, test := range cases { + t.Run(test.declaration.Command, func(t *testing.T) { + unconfirmed := &dingCoverageCaller{responses: map[string][]string{}} + err := runDingRoot(t, test.declaration, unconfirmed, false, test.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, test.declaration, confirmed, true, test.args...) + if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 { + t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history) + } + }) + } +} + +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/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index c1fa1f33..edf28fd4 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": {}, diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 35efab99..535a3f0d 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -43,6 +43,9 @@ var mailSemanticCatalogJSON []byte //go:embed semantic_catalog_oa.json var oaSemanticCatalogJSON []byte +//go:embed semantic_catalog_ding.json +var dingSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -73,6 +76,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( attendanceSemanticCatalogJSON, mailSemanticCatalogJSON, oaSemanticCatalogJSON, + dingSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json new file mode 100644 index 00000000..46f34233 --- /dev/null +++ b/internal/shortcut/semantic_catalog_ding.json @@ -0,0 +1,43 @@ +{ + "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, + "reviewed": true + }, + "+receiver-status": { + "disposition": "semantic_adapter", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+send-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "用户身份发送 DING 的原子能力存在,但下游没有同时提供稳定接收人回执、精确读回和可验证零残留清理;确认后也在本地 capability gate 停止。", + "risk": "write", + "public": false, + "reviewed": true + }, + "+send-by-message": { + "disposition": "semantic_adapter", + "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING,映射 lark-cli 三种 urgent 任务;缺少可逆 fixture、稳定回执和精确清理证明,保持不可用。", + "risk": "write", + "public": false, + "reviewed": true + }, + "+recall-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "个人 DING 撤回缺少能证明目标已撤回且通知无残留的精确终态读取;不接受单一 success 回执作为成功,保持不可用。", + "risk": "high-risk-write", + "public": false, + "reviewed": true + } + } +} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 41ae4e61..7d4fa695 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -30,6 +30,7 @@ SEMANTIC_PATHS = [ 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", ]