271 lines
8.9 KiB
Go
271 lines
8.9 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
package drive
|
|
|
|
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"
|
|
)
|
|
|
|
const driveCompositeInterfaceReason = "Reviewed Drive Shortcut composite: the executable CLI owns strict response validation, optional multi-step orchestration, local I/O, read-back verification, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
|
|
|
func driveContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
|
|
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
|
cliPath := "drive " + command
|
|
return corecmd.ContractDecl{
|
|
Description: description,
|
|
Parameters: params,
|
|
Result: result,
|
|
Pagination: pagination,
|
|
Interface: &contract.InterfaceSpec{
|
|
Mode: contract.InterfaceModeComposite,
|
|
Availability: contract.InterfaceAvailable,
|
|
Reason: driveCompositeInterfaceReason,
|
|
},
|
|
Selection: contract.SelectionSpec{
|
|
AgentSummary: description,
|
|
UseWhen: []string{useWhen},
|
|
AvoidWhen: avoidWhen,
|
|
Examples: examples,
|
|
},
|
|
Identity: contract.ToolIdentitySpec{
|
|
ProductID: "drive",
|
|
Name: name,
|
|
CanonicalPath: "drive." + name,
|
|
CLIPath: cliPath,
|
|
PrimaryCLIPath: cliPath,
|
|
},
|
|
}
|
|
}
|
|
|
|
func driveObjectResult(description string) *contract.ResultSpec {
|
|
return &contract.ResultSpec{
|
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
|
DataSchema: json.RawMessage(fmt.Sprintf(
|
|
`{"type":"object","description":%q,"additionalProperties":true}`,
|
|
description,
|
|
)),
|
|
}
|
|
}
|
|
|
|
func driveCollectionResult(collection, description string) *contract.ResultSpec {
|
|
return &contract.ResultSpec{
|
|
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
|
DataSchema: json.RawMessage(fmt.Sprintf(
|
|
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"本页有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Drive 资源条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`,
|
|
description, collection, description, collection,
|
|
)),
|
|
}
|
|
}
|
|
|
|
func driveCursorPagination() *contract.PaginationSpec {
|
|
return &contract.PaginationSpec{
|
|
Kind: contract.PaginationKindCursor,
|
|
CursorParameter: "cursor",
|
|
MetaPath: contract.PaginationMetaPath,
|
|
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
|
NextTokenPath: contract.PaginationNextTokenPath,
|
|
}
|
|
}
|
|
|
|
func requireDriveResponse(data map[string]any, operation string) (map[string]any, error) {
|
|
if len(data) == 0 {
|
|
return nil, driveResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功")
|
|
}
|
|
if success, present := data["success"]; present {
|
|
value, ok := success.(bool)
|
|
if !ok {
|
|
return nil, driveResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
|
}
|
|
if !value {
|
|
message := firstString(data, "errorMsg", "message", "error")
|
|
if message == "" {
|
|
message = "服务明确返回 success=false"
|
|
}
|
|
return nil, driveResponseError(operation, "remote_failure", message)
|
|
}
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func requireDriveObject(data map[string]any, operation string) (map[string]any, error) {
|
|
data, err := requireDriveResponse(data, operation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if value, present := data["result"]; present {
|
|
result, ok := value.(map[string]any)
|
|
if !ok || len(result) == 0 {
|
|
return nil, driveResponseError(operation, "malformed_result", "响应 result 缺失有效对象")
|
|
}
|
|
return result, nil
|
|
}
|
|
if value, present := data["data"]; present {
|
|
result, ok := value.(map[string]any)
|
|
if !ok || len(result) == 0 {
|
|
return nil, driveResponseError(operation, "malformed_data", "响应 data 缺失有效对象")
|
|
}
|
|
return result, nil
|
|
}
|
|
if len(data) == 1 {
|
|
return nil, driveResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func requireDriveWrite(data map[string]any, operation string) (map[string]any, error) {
|
|
data, err := requireDriveResponse(data, operation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
success, ok := data["success"].(bool)
|
|
if !ok || !success {
|
|
return nil, driveResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func requireDriveCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
|
|
data, err := requireDriveResponse(data, operation)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
containers := []map[string]any{data}
|
|
for _, wrapper := range []string{"result", "data"} {
|
|
if value, present := data[wrapper]; present {
|
|
inner, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil, nil, driveResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper))
|
|
}
|
|
containers = append(containers, inner)
|
|
}
|
|
}
|
|
for _, container := range containers {
|
|
for _, key := range keys {
|
|
value, present := container[key]
|
|
if !present {
|
|
continue
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, nil, driveResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
|
|
}
|
|
for index, item := range items {
|
|
if _, ok := item.(map[string]any); !ok {
|
|
return nil, nil, driveResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index))
|
|
}
|
|
}
|
|
return items, container, nil
|
|
}
|
|
}
|
|
return nil, nil, driveResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段投影成空结果")
|
|
}
|
|
|
|
func projectDriveRows(items []any, aliases map[string][]string) []map[string]any {
|
|
rows := make([]map[string]any, 0, len(items))
|
|
for _, item := range items {
|
|
source := item.(map[string]any)
|
|
row := make(map[string]any)
|
|
for canonical, candidates := range aliases {
|
|
for _, candidate := range candidates {
|
|
if value, ok := source[candidate]; ok && value != nil {
|
|
row[canonical] = value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func addDrivePagination(out map[string]any, container map[string]any) {
|
|
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"hasMore", "hasMore"}} {
|
|
if value, ok := container[pair[0]]; ok && value != nil {
|
|
out[pair[1]] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
func firstString(data map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nestedString(data map[string]any, keys ...string) string {
|
|
if value := firstString(data, keys...); value != "" {
|
|
return value
|
|
}
|
|
for _, wrapper := range []string{"result", "data"} {
|
|
if inner, ok := data[wrapper].(map[string]any); ok {
|
|
if value := firstString(inner, keys...); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func driveReadbackNameMatches(data map[string]any, requested string) bool {
|
|
remoteName := firstString(data, "name", "fileName")
|
|
if remoteName == requested {
|
|
return true
|
|
}
|
|
extension := strings.TrimLeft(firstString(data, "extension", "fileExtension", "ext"), ".")
|
|
return extension != "" && remoteName+"."+extension == requested
|
|
}
|
|
|
|
func firstInt64(data map[string]any, keys ...string) (int64, bool) {
|
|
for _, key := range keys {
|
|
value, present := data[key]
|
|
if !present {
|
|
continue
|
|
}
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return int64(typed), true
|
|
case int32:
|
|
return int64(typed), true
|
|
case int64:
|
|
return typed, true
|
|
case float64:
|
|
if !math.IsNaN(typed) && !math.IsInf(typed, 0) && typed == math.Trunc(typed) && typed >= math.MinInt64 && typed < math.MaxInt64 {
|
|
return int64(typed), true
|
|
}
|
|
case json.Number:
|
|
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
|
|
if err == nil {
|
|
return parsed, true
|
|
}
|
|
case string:
|
|
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
|
if err == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func driveResponseError(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),
|
|
)
|
|
}
|