317 lines
14 KiB
Go
317 lines
14 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
package ding
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
|
)
|
|
|
|
const (
|
|
dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection."
|
|
dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete."
|
|
dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts."
|
|
)
|
|
|
|
type dingPageEvidence struct {
|
|
HasMore bool
|
|
Next string
|
|
}
|
|
|
|
func dingReadSafety() contract.SafetySpec {
|
|
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
|
}
|
|
|
|
func dingWriteSafety(destructive bool) contract.SafetySpec {
|
|
effect, risk := "write", "medium"
|
|
if destructive {
|
|
effect, risk = "destructive", "high"
|
|
}
|
|
return contract.SafetySpec{Effect: effect, Risk: risk, Confirmation: "user_required", Idempotency: "unknown"}
|
|
}
|
|
|
|
func dingListResult() *contract.ResultSpec {
|
|
return &contract.ResultSpec{
|
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
|
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的 DING 消息页","properties":{"count":{"type":"integer","description":"当前页有效 DING 消息数量"},"messages":{"type":"array","description":"严格验证并投影后的 DING 消息","items":{"type":"object","description":"一条具有稳定 openDingId 的 DING 消息","properties":{"openDingId":{"type":"string","description":"稳定 DING 消息 ID"},"content":{"type":"string","description":"DING 消息内容"},"sourceMessageId":{"type":"string","description":"作为 DING 来源的聊天消息 ID"},"sendTime":{"type":"string","description":"服务端返回的发送时间"},"senderNick":{"type":"string","description":"发送人显示名"}},"required":["openDingId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"当前页是否具有服务端分页终态证据"}},"required":["count","messages","complete"],"additionalProperties":false}`),
|
|
SensitivePaths: []string{"messages.content", "messages.senderNick"},
|
|
}
|
|
}
|
|
|
|
func dingReceiverResult() *contract.ResultSpec {
|
|
return &contract.ResultSpec{
|
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
|
DataSchema: json.RawMessage(`{"type":"object","description":"指定 DING 的严格接收状态","properties":{"count":{"type":"integer","description":"经验证的接收状态数量"},"receivers":{"type":"array","description":"身份匹配的 DING 接收状态","items":{"type":"object","description":"一名接收人的 DING 确认状态","properties":{"openDingId":{"type":"string","description":"与请求一致的稳定 DING ID"},"confirmedStatus":{"type":"integer","description":"服务端 DING 确认状态码"},"receiverNick":{"type":"string","description":"接收人显示名"}},"required":["openDingId","confirmedStatus","receiverNick"],"additionalProperties":false}}},"required":["count","receivers"],"additionalProperties":false}`),
|
|
SensitivePaths: []string{"receivers.receiverNick"},
|
|
}
|
|
}
|
|
|
|
func dingPagination() *contract.PaginationSpec {
|
|
return &contract.PaginationSpec{
|
|
Kind: contract.PaginationKindCursor,
|
|
CursorParameter: "cursor",
|
|
MetaPath: contract.PaginationMetaPath,
|
|
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
|
NextTokenPath: contract.PaginationNextTokenPath,
|
|
}
|
|
}
|
|
|
|
func dingContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
|
|
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
|
cliPath := "ding " + command
|
|
availability, reason := contract.InterfaceAvailable, dingCompositeReason
|
|
if !available {
|
|
availability, reason = contract.InterfaceUnavailable, dingWriteUnavailableReason
|
|
}
|
|
return corecmd.ContractDecl{
|
|
Description: description,
|
|
Result: result,
|
|
Pagination: pagination,
|
|
Parameters: params,
|
|
Identity: contract.ToolIdentitySpec{
|
|
ProductID: "ding", Name: name, CanonicalPath: "ding." + name,
|
|
CLIPath: cliPath, PrimaryCLIPath: cliPath,
|
|
},
|
|
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason},
|
|
Selection: contract.SelectionSpec{
|
|
AgentSummary: description,
|
|
UseWhen: []string{intent},
|
|
AvoidWhen: []string{"普通聊天消息使用 chat;写能力在缺少可逆真实 fixture、精确读回或清理证据时不要执行"},
|
|
Examples: examples,
|
|
},
|
|
}
|
|
}
|
|
|
|
func dingResponseError(operation, reason, message string) error {
|
|
return apperrors.NewAPI(message,
|
|
apperrors.WithOperation(operation),
|
|
apperrors.WithOrigin("mcp"),
|
|
apperrors.WithFailureStage("response_validation"),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithReason(reason),
|
|
)
|
|
}
|
|
|
|
func dingRequireResult(data map[string]any, operation string) (map[string]any, error) {
|
|
if len(data) == 0 {
|
|
return nil, dingResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 DING 调用成功或结果确实为空")
|
|
}
|
|
success, present := data["success"]
|
|
if !present {
|
|
return nil, dingResponseError(operation, "missing_success", "DING 响应缺少 success 业务状态")
|
|
}
|
|
succeeded, ok := success.(bool)
|
|
if !ok {
|
|
return nil, dingResponseError(operation, "invalid_success_type", fmt.Sprintf("DING 响应 success 应为布尔值,实际为 %T", success))
|
|
}
|
|
if !succeeded {
|
|
message := "DING 服务明确返回失败"
|
|
for _, key := range []string{"errorMsg", "errorMessage", "message"} {
|
|
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
|
message = strings.TrimSpace(value)
|
|
break
|
|
}
|
|
}
|
|
return nil, dingResponseError(operation, "remote_failure", message)
|
|
}
|
|
result, ok := data["result"].(map[string]any)
|
|
if !ok || result == nil {
|
|
return nil, dingResponseError(operation, "missing_result", "DING 成功响应缺少 result 对象")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func dingRequireObjectCollection(result map[string]any, operation, field string) ([]map[string]any, error) {
|
|
raw, present := result[field]
|
|
if !present {
|
|
return nil, dingResponseError(operation, "missing_collection", fmt.Sprintf("DING 成功响应缺少 result.%s 数组", field))
|
|
}
|
|
values, ok := raw.([]any)
|
|
if !ok {
|
|
return nil, dingResponseError(operation, "malformed_collection", fmt.Sprintf("DING result.%s 应为数组,实际为 %T", field, raw))
|
|
}
|
|
items := make([]map[string]any, 0, len(values))
|
|
for index, value := range values {
|
|
item, ok := value.(map[string]any)
|
|
if !ok || len(item) == 0 {
|
|
return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING result.%s[%d] 不是非空对象", field, index))
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func dingRequiredString(item map[string]any, operation, field string, index int) (string, error) {
|
|
value, ok := item[field].(string)
|
|
value = strings.TrimSpace(value)
|
|
if !ok || value == "" {
|
|
return "", dingResponseError(operation, "missing_item_identity", fmt.Sprintf("DING 结果第 %d 项缺少非空 %s", index, field))
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func dingOptionalString(item map[string]any, field string) (string, error) {
|
|
value, present := item[field]
|
|
if !present || value == nil {
|
|
return "", nil
|
|
}
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("字段 %s 应为字符串,实际为 %T", field, value)
|
|
}
|
|
return strings.TrimSpace(text), nil
|
|
}
|
|
|
|
func dingInteger(value any) (int64, bool) {
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 {
|
|
return 0, false
|
|
}
|
|
return int64(typed), true
|
|
case int:
|
|
return int64(typed), true
|
|
case int64:
|
|
return typed, true
|
|
case json.Number:
|
|
parsed, err := typed.Int64()
|
|
return parsed, err == nil
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func dingProjectMessages(data map[string]any, operation string) ([]map[string]any, dingPageEvidence, error) {
|
|
result, err := dingRequireResult(data, operation)
|
|
if err != nil {
|
|
return nil, dingPageEvidence{}, err
|
|
}
|
|
items, err := dingRequireObjectCollection(result, operation, "dingMessages")
|
|
if err != nil {
|
|
return nil, dingPageEvidence{}, err
|
|
}
|
|
messages := make([]map[string]any, 0, len(items))
|
|
seen := make(map[string]struct{}, len(items))
|
|
for index, item := range items {
|
|
id, identityErr := dingRequiredString(item, operation, "openDingId", index)
|
|
if identityErr != nil {
|
|
return nil, dingPageEvidence{}, identityErr
|
|
}
|
|
if _, duplicate := seen[id]; duplicate {
|
|
return nil, dingPageEvidence{}, dingResponseError(operation, "duplicate_item_identity", "当前 DING 页包含重复 openDingId")
|
|
}
|
|
seen[id] = struct{}{}
|
|
projected := map[string]any{"openDingId": id}
|
|
for source, target := range map[string]string{
|
|
"dingContent": "content", "dingSourceOpenMessageId": "sourceMessageId",
|
|
"sendTime": "sendTime", "senderNick": "senderNick",
|
|
} {
|
|
value, valueErr := dingOptionalString(item, source)
|
|
if valueErr != nil {
|
|
return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 结果第 %d 项%s", index, valueErr.Error()))
|
|
}
|
|
if value != "" {
|
|
projected[target] = value
|
|
}
|
|
}
|
|
messages = append(messages, projected)
|
|
}
|
|
hasMoreValue, present := result["hasMore"]
|
|
if !present {
|
|
return nil, dingPageEvidence{}, dingResponseError(operation, "missing_pagination", "DING 列表缺少 result.hasMore,不能宣称当前页完整")
|
|
}
|
|
hasMore, ok := hasMoreValue.(bool)
|
|
if !ok {
|
|
return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_pagination", "DING result.hasMore 应为布尔值")
|
|
}
|
|
page := dingPageEvidence{HasMore: hasMore}
|
|
if !hasMore {
|
|
if next, exists := result["nextCursor"]; exists && next != nil {
|
|
if numeric, valid := dingInteger(next); !valid || numeric != 0 {
|
|
return nil, dingPageEvidence{}, dingResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 仍表示后续页")
|
|
}
|
|
}
|
|
return messages, page, nil
|
|
}
|
|
if len(messages) == 0 {
|
|
return nil, dingPageEvidence{}, dingResponseError(operation, "empty_page_with_continuation", "DING 空页仍声明 hasMore=true,无法证明游标可安全推进")
|
|
}
|
|
next, valid := dingInteger(result["nextCursor"])
|
|
if !valid || next <= 0 {
|
|
return nil, dingPageEvidence{}, dingResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 nextCursor")
|
|
}
|
|
page.Next = strconv.FormatInt(next, 10)
|
|
return messages, page, nil
|
|
}
|
|
|
|
func dingProjectReceivers(data map[string]any, operation, expectedID string) ([]map[string]any, error) {
|
|
result, err := dingRequireResult(data, operation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := dingRequireObjectCollection(result, operation, "receivers")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
return nil, dingResponseError(operation, "empty_receiver_status", "DING 接收状态显式为空,无法证明目标 DING 的接收状态")
|
|
}
|
|
receivers := make([]map[string]any, 0, len(items))
|
|
for index, item := range items {
|
|
id, identityErr := dingRequiredString(item, operation, "openDingId", index)
|
|
if identityErr != nil {
|
|
return nil, identityErr
|
|
}
|
|
if id != expectedID {
|
|
return nil, dingResponseError(operation, "identity_mismatch", "DING 接收状态的 openDingId 与请求目标不一致")
|
|
}
|
|
status, ok := dingInteger(item["confirmedStatus"])
|
|
if !ok {
|
|
return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 接收状态第 %d 项 confirmedStatus 不是整数", index))
|
|
}
|
|
nick, nickErr := dingRequiredString(item, operation, "receiverNick", index)
|
|
if nickErr != nil {
|
|
return nil, nickErr
|
|
}
|
|
receivers = append(receivers, map[string]any{"openDingId": id, "confirmedStatus": status, "receiverNick": nick})
|
|
}
|
|
return receivers, nil
|
|
}
|
|
|
|
func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page dingPageEvidence) error {
|
|
payload := map[string]any{"count": len(messages), "messages": messages, "complete": !page.HasMore}
|
|
if !output.UsesUnifiedResult(rt.Command()) {
|
|
if page.HasMore {
|
|
payload["nextCursor"] = page.Next
|
|
}
|
|
return rt.Output(payload)
|
|
}
|
|
pagination, err := output.NewPagination(!page.HasMore, page.Next)
|
|
if err != nil {
|
|
return dingResponseError("im/list_ding_messages", "invalid_pagination", err.Error())
|
|
}
|
|
meta := &output.Meta{Count: output.NewCount(len(messages)), Pagination: pagination}
|
|
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
|
|
}
|
|
|
|
func dingUnavailable(operation string) error {
|
|
return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行",
|
|
apperrors.WithOperation(operation),
|
|
apperrors.WithOrigin("shortcut_registry"),
|
|
apperrors.WithFailureStage("capability_gate"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithReason("write_fixture_unavailable"),
|
|
)
|
|
}
|