357 lines
13 KiB
Go
357 lines
13 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
package doc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"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/shortcut"
|
|
)
|
|
|
|
const (
|
|
compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
|
docContentInputDescription = "内容字面量、@工作目录相对文件或 - 表示 stdin"
|
|
docRequiredContentInputDescription = docContentInputDescription + ";相关动作要求时不能为空"
|
|
)
|
|
|
|
func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl {
|
|
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
|
cliPath := "doc " + command
|
|
return corecmd.ContractDecl{
|
|
Description: description,
|
|
Parameters: params,
|
|
Interface: &contract.InterfaceSpec{
|
|
Mode: contract.InterfaceModeComposite,
|
|
Availability: contract.InterfaceAvailable,
|
|
Reason: compositeInterfaceReason,
|
|
},
|
|
Selection: contract.SelectionSpec{
|
|
AgentSummary: description,
|
|
UseWhen: []string{intent},
|
|
AvoidWhen: []string{
|
|
"需要文件树移动、复制或普通钉盘文件操作时改用 drive;非文字文档按对象类型路由到 sheet、aitable、slides 或 wiki",
|
|
},
|
|
Examples: examples,
|
|
},
|
|
Identity: contract.ToolIdentitySpec{
|
|
ProductID: "doc",
|
|
Name: name,
|
|
CanonicalPath: "doc." + name,
|
|
CLIPath: cliPath,
|
|
PrimaryCLIPath: cliPath,
|
|
},
|
|
}
|
|
}
|
|
|
|
func withDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
|
|
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
|
|
return decl
|
|
}
|
|
|
|
func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, error) {
|
|
raw := rt.Str(flag)
|
|
if raw == "-" {
|
|
data, err := io.ReadAll(rt.Command().InOrStdin())
|
|
if err != nil {
|
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取 stdin 失败: %v", flag, err))
|
|
}
|
|
return string(data), nil
|
|
}
|
|
if !strings.HasPrefix(raw, "@") {
|
|
return raw, nil
|
|
}
|
|
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
|
if path == "" || filepath.IsAbs(path) {
|
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
|
|
}
|
|
cwd, err := docGetwd()
|
|
if err != nil {
|
|
return "", apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
|
|
}
|
|
realBase, err := docEvalSymlinks(cwd)
|
|
if err != nil {
|
|
return "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
|
|
}
|
|
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
|
|
if err != nil {
|
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
|
|
}
|
|
rel, err := docRel(realBase, realPath)
|
|
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
|
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
|
|
}
|
|
data, err := docReadFile(realPath)
|
|
if err != nil {
|
|
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func validateWorkspaceInputPath(flag, raw string) error {
|
|
path := strings.TrimSpace(raw)
|
|
if path == "" || filepath.IsAbs(path) {
|
|
return apperrors.NewValidation(fmt.Sprintf("--%s 只接受工作目录内已暂存文件的相对路径", flag))
|
|
}
|
|
cwd, err := docGetwd()
|
|
if err != nil {
|
|
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
|
|
}
|
|
realBase, err := docEvalSymlinks(cwd)
|
|
if err != nil {
|
|
return apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
|
|
}
|
|
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
|
|
if err != nil {
|
|
return apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
|
|
}
|
|
rel, err := docRel(realBase, realPath)
|
|
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
|
|
return apperrors.NewValidation(fmt.Sprintf("--%s 不能逃逸工作目录;请先把文件暂存到工作目录", flag))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateJSONML(raw string) (string, error) {
|
|
var value any
|
|
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
|
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
|
|
}
|
|
root, ok := value.([]any)
|
|
if !ok {
|
|
return "", apperrors.NewValidation("JSONML 顶层必须是数组")
|
|
}
|
|
if len(root) == 0 {
|
|
return "", apperrors.NewValidation("JSONML 顶层数组不能为空")
|
|
}
|
|
if _, nestedRoot := root[0].([]any); nestedRoot {
|
|
return "", apperrors.NewValidation("JSONML 顶层必须是单个元素,不能使用元素数组包裹;请传 [\"root\", {...}, ...] 或单个 block 元素")
|
|
}
|
|
if tag, ok := root[0].(string); !ok || strings.TrimSpace(tag) == "" {
|
|
return "", apperrors.NewValidation("JSONML 第一个元素必须是非空标签名")
|
|
}
|
|
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
|
|
return string(normalized), nil
|
|
}
|
|
|
|
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
|
|
return map[string]any{
|
|
"contractVersion": "doc.operation.v1",
|
|
"ok": true,
|
|
"status": "success",
|
|
"complete": true,
|
|
"operation": operation,
|
|
"steps": steps,
|
|
"data": data,
|
|
"warnings": []string{},
|
|
"compensation": map[string]any{"available": false, "reason": ""},
|
|
}
|
|
}
|
|
|
|
type docFailureState string
|
|
|
|
const (
|
|
docFailureFailed docFailureState = "failed"
|
|
docFailureRetryable docFailureState = "retryable"
|
|
docFailureUnknown docFailureState = "unknown"
|
|
)
|
|
|
|
type docFailureTransition struct {
|
|
state docFailureState
|
|
reason string
|
|
retryable bool
|
|
executionStarted bool
|
|
message string
|
|
actions []string
|
|
}
|
|
|
|
func classifyDocWriteFailure(cause error) docFailureTransition {
|
|
transition := docFailureTransition{
|
|
state: docFailureUnknown,
|
|
reason: "doc_write_commit_unknown",
|
|
retryable: false,
|
|
executionStarted: true,
|
|
message: "文档写入结果未知;为避免重复创建或重复追加,已停止自动重试",
|
|
actions: []string{"先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"},
|
|
}
|
|
var typed *apperrors.Error
|
|
if !errors.As(cause, &typed) {
|
|
return transition
|
|
}
|
|
reason := strings.ToLower(strings.TrimSpace(typed.Reason))
|
|
message := strings.ToLower(typed.Message)
|
|
if typed.Category == apperrors.CategoryValidation {
|
|
if reason == "" {
|
|
reason = "invalid_input"
|
|
}
|
|
return docFailureTransition{
|
|
state: docFailureFailed,
|
|
reason: reason,
|
|
retryable: false,
|
|
executionStarted: false,
|
|
message: typed.Message,
|
|
actions: []string{"根据错误中的参数约束修正输入后重新执行"},
|
|
}
|
|
}
|
|
permissionDenied := strings.Contains(reason, "403") || strings.Contains(reason, "permission") ||
|
|
strings.Contains(message, "permission") || strings.Contains(message, "forbidden") || strings.Contains(message, "权限")
|
|
if permissionDenied {
|
|
return docFailureTransition{
|
|
state: docFailureFailed,
|
|
reason: "permission_denied",
|
|
retryable: false,
|
|
executionStarted: false,
|
|
message: "当前身份没有执行该文档操作的权限;已终止,不会重试或改走同义命令",
|
|
actions: []string{"检查当前 profile 与文档权限", "获得权限后重新发起一次原操作"},
|
|
}
|
|
}
|
|
if typed.Category == apperrors.CategoryAuth {
|
|
return docFailureTransition{
|
|
state: docFailureFailed,
|
|
reason: "authentication_required",
|
|
retryable: false,
|
|
executionStarted: false,
|
|
message: "当前身份认证不可用;已终止文档写入",
|
|
actions: []string{"检查当前 profile 与登录状态", "认证恢复后重新发起一次原操作"},
|
|
}
|
|
}
|
|
if typed.ExecutionStarted != nil && !*typed.ExecutionStarted {
|
|
transition.state = docFailureFailed
|
|
transition.executionStarted = false
|
|
transition.retryable = typed.RetryableSet && typed.Retryable
|
|
if transition.retryable {
|
|
transition.state = docFailureRetryable
|
|
}
|
|
if reason != "" {
|
|
transition.reason = reason
|
|
} else {
|
|
transition.reason = "doc_write_not_started"
|
|
}
|
|
transition.message = "文档写入已确认尚未开始"
|
|
transition.actions = []string{"仅在 retryable=true 时按服务端 retry-after 有界重试一次"}
|
|
}
|
|
return transition
|
|
}
|
|
|
|
func docUnknownWriteError(operation, stage, nodeID string, cause error) error {
|
|
var existing *apperrors.Error
|
|
if errors.As(cause, &existing) {
|
|
if existing.Category == apperrors.CategoryValidation {
|
|
return cause
|
|
}
|
|
if status, ok := existing.Details["status"].(string); ok && (status == "unknown" || status == "partial_success") {
|
|
return cause
|
|
}
|
|
}
|
|
transition := classifyDocWriteFailure(cause)
|
|
details := map[string]any{
|
|
"contractVersion": "doc.operation.v1",
|
|
"status": string(transition.state),
|
|
"state": string(transition.state),
|
|
"nodeId": nodeID,
|
|
"stage": stage,
|
|
}
|
|
options := []apperrors.Option{
|
|
apperrors.WithOperation(operation),
|
|
apperrors.WithReason(transition.reason),
|
|
apperrors.WithFailureStage(stage),
|
|
apperrors.WithExecutionStarted(transition.executionStarted),
|
|
apperrors.WithRetryable(transition.retryable),
|
|
apperrors.WithActions(transition.actions...),
|
|
apperrors.WithDetails(details),
|
|
apperrors.WithCause(cause),
|
|
}
|
|
if transition.reason == "permission_denied" || transition.reason == "authentication_required" {
|
|
return apperrors.NewAuth(transition.message, options...)
|
|
}
|
|
return apperrors.NewAPI(transition.message, options...)
|
|
}
|
|
|
|
func docVerificationError(operation, stage, nodeID string, cause error, steps []map[string]any) error {
|
|
return docPartialWriteError(
|
|
operation,
|
|
"doc_write_verification_failed",
|
|
stage,
|
|
"文档写入已经执行,但回读验证失败;请先检查当前内容,不要直接重试写入",
|
|
cause,
|
|
map[string]any{"nodeId": nodeID, "verified": false},
|
|
steps,
|
|
map[string]any{"available": false, "reason": "inspect the current document before choosing recovery"},
|
|
)
|
|
}
|
|
|
|
func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error {
|
|
return apperrors.NewAPI(
|
|
message,
|
|
apperrors.WithOperation(operation),
|
|
apperrors.WithReason(reason),
|
|
apperrors.WithFailureStage(stage),
|
|
apperrors.WithExecutionStarted(true),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"),
|
|
apperrors.WithDetails(map[string]any{
|
|
"contractVersion": "doc.operation.v1",
|
|
"status": "partial_success",
|
|
"data": data,
|
|
"steps": steps,
|
|
"compensation": compensation,
|
|
}),
|
|
apperrors.WithCause(cause),
|
|
)
|
|
}
|
|
|
|
func nestedString(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)
|
|
}
|
|
}
|
|
for _, wrapper := range []string{"result", "data", "content"} {
|
|
if inner, ok := data[wrapper].(map[string]any); ok {
|
|
if value := nestedString(inner, keys...); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nestedMap(data map[string]any) map[string]any {
|
|
for _, wrapper := range []string{"result", "data"} {
|
|
if inner, ok := data[wrapper].(map[string]any); ok {
|
|
return nestedMap(inner)
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func stringSliceNonEmpty(values []string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// blockIdentity normalizes the currently observed block response shapes. The
|
|
// element API returns element.id, while JSONML and older payloads use blockId
|
|
// or uuid. Callers pass an inherited parent identity for nested text maps.
|
|
func blockIdentity(values map[string]any, inherited string) string {
|
|
for _, key := range []string{"blockId", "id", "uuid"} {
|
|
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return inherited
|
|
}
|