feat(shortcut): harden DING reads and availability
This commit is contained in:
1 parent
2cb83d388b
commit
4da5a07d1d
11 files changed
+880
-211
No files matched your search
@@ -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"),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user