feat(shortcut): harden DING reads and availability

This commit is contained in:
Dennis committed 2026-08-20 20:19:30 +08:00
1 parent 2cb83d388b
commit 4da5a07d1d
11 files changed
+880 -211

No files matched your search

@@ -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:真实证据仅记录命令级标签和聚合计数;未将本地诊断响应提交到仓库。
+5 -23
View File
@@ -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",
@@ -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
@@ -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)
}
}
+315
View File
@@ -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"),
)
}
+119 -184
View File
@@ -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 <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 <DING_ID>",
),
Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
Tips: []string{"dws ding +receiver-status --ding-id <DING_ID>"},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("list_ding_receiver_status", map[string]any{
"openDingId": rt.Str("ding-id"),
})
const operation = "im/list_ding_receiver_status"
data, err := rt.CallMCPData("im", "list_ding_receiver_status", map[string]any{"openDingId": rt.Str("ding-id")})
if err != nil {
return err
}
receivers, err := dingProjectReceivers(data, operation, rt.Str("ding-id"))
if err != nil {
return err
}
payload := map[string]any{"count": len(receivers), "receivers": receivers}
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
meta := &output.Meta{Count: output.NewCount(len(receivers))}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
},
}
var SendPersonal = shortcut.Shortcut{
Service: "ding", Command: "+send-personal", Product: "im",
Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "ding",
Name: "shortcut_send_personal",
CanonicalPath: "ding.shortcut_send_personal",
CLIPath: "ding +send-personal",
PrimaryCLIPath: "ding +send-personal",
},
Description: "以本人身份发送 DING 给指定人",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "以本人身份发送 DING 给指定人",
UseWhen: []string{"当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws ding +send-personal --users <VALUES> --content <CONTENT>"},
},
},
Flags: []shortcut.Flag{
var SendPersonal = 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 <VALUES> --content <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 <GROUP_ID> --message-id <MESSAGE_ID> --users <VALUES>",
)
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 <DING_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 <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)
}
@@ -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()
}
@@ -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": {},
+4
View File
@@ -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 {
@@ -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
}
}
}
+1
View File
@@ -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",
]