Files
dingtalk-workspace-cli/internal/shortcut/doc/common.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
}