package helpers import ( "encoding/json" "fmt" "strings" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) const ( ExitSuccess = 0 ExitAPI = 1 ExitAuth = 2 ExitValidation = 3 ExitPermission = 4 ExitInternal = 5 ) const ( CodeAuthNotConfigured = "AUTH_NOT_CONFIGURED" CodeAuthTokenExpired = "AUTH_TOKEN_EXPIRED" CodeAuthPermission = "AUTH_PERMISSION_DENIED" CodeNetworkTimeout = "NETWORK_TIMEOUT" CodeNetworkUnreachable = "NETWORK_UNREACHABLE" CodeLockTimeout = "LOCK_TIMEOUT" CodeResourceNotFound = "RESOURCE_NOT_FOUND" CodeTableNotFound = "TABLE_NOT_FOUND" CodeSheetNotFound = "SHEET_NOT_FOUND" CodeFieldNotFound = "FIELD_NOT_FOUND" CodeRecordNotFound = "RECORD_NOT_FOUND" CodeInvalidJSON = "INPUT_INVALID_JSON" CodeInvalidPath = "INPUT_INVALID_PATH" CodeInputTooLarge = "INPUT_TOO_LARGE" CodeMissingParam = "INPUT_MISSING_PARAM" CodeInvalidParam = "INPUT_INVALID_PARAM" CodeFileNotFound = "INPUT_FILE_NOT_FOUND" CodeContentTruncated = "CONTENT_TRUNCATED" CodeMCPServerError = "MCP_SERVER_ERROR" CodeMCPToolError = "MCP_TOOL_ERROR" CodeUnclassified = "UNCLASSIFIED" ) // CLIError is a user-friendly error with code, suggestion, and exit code. // Supports traceability via the Operation field and error chain via Cause. type CLIError struct { Code string Message string Suggestion string Operation string // the operation that failed (for traceability) Cause error } func (e *CLIError) Error() string { s := fmt.Sprintf("[%s] %s", e.Code, e.Message) if e.Operation != "" { s += fmt.Sprintf(" (operation: %s)", e.Operation) } if e.Suggestion != "" { s += fmt.Sprintf("\n hint: %s", e.Suggestion) } return s } func (e *CLIError) Unwrap() error { return e.Cause } func (e *CLIError) ExitCode() int { switch e.Code { case CodeAuthNotConfigured, CodeAuthTokenExpired: return ExitAuth case CodeAuthPermission: return ExitPermission case CodeMissingParam, CodeInvalidParam, CodeInvalidJSON, CodeInvalidPath, CodeInputTooLarge, CodeFileNotFound: return ExitValidation case CodeContentTruncated: return ExitAPI case CodeMCPServerError, CodeMCPToolError: return ExitAPI case CodeNetworkTimeout, CodeNetworkUnreachable: return ExitAPI case CodeResourceNotFound, CodeTableNotFound, CodeSheetNotFound, CodeFieldNotFound, CodeRecordNotFound: return ExitAPI case CodeLockTimeout, CodeUnclassified: return ExitInternal default: return ExitInternal } } // ToJSON returns a structured JSON representation for machine consumption. func (e *CLIError) ToJSON() map[string]any { errMap := map[string]any{ "code": e.Code, "message": e.Message, "exit_code": e.ExitCode(), } if e.Operation != "" { errMap["operation"] = e.Operation } if e.Suggestion != "" { errMap["suggestion"] = e.Suggestion } if e.Cause != nil { errMap["cause"] = e.Cause.Error() } return map[string]any{"error": errMap} } // PATError represents a PAT authorization failure that should be passed through // to stderr as raw JSON without any CLI-layer wrapping. type PATError struct { RawJSON string } func (e *PATError) Error() string { return e.RawJSON } func (e *PATError) ExitCode() int { return ExitPermission } func (e *PATError) RawStderr() string { return e.RawJSON } // WrapError analyzes a raw error and wraps it with a friendly message. // It classifies errors by pattern (network, auth, permission, resource, // JSON parse, server error, file lock) and returns an appropriate CLIError. func WrapError(err error) error { return WrapErrorWithOperation(err, "") } // WrapErrorWithOperation wraps an error with operation context for traceability. func WrapErrorWithOperation(err error, operation string) error { if err == nil { return nil } if _, ok := err.(*CLIError); ok { return err } if _, ok := err.(*PATError); ok { return err } // 框架确认门禁错误(deferred ConfirmSafety 从 CallTool 返回)必须原样透传: // “加 --yes 重试”的语义只能由 reason=confirmation_required 表达,文本分类会 // 误路由(commandPath 含 "permission" 的命令会被判成 AUTH_PERMISSION_DENIED, // 其余则丢失 reason 退化为 UNCLASSIFIED)。 if apperrors.IsConfirmationRequired(err) { return err } msg := err.Error() // File lock timeout (cross-process lock) if errContainsAny(msg, "timeout acquiring data lock", "timeout acquiring token lock") { return &CLIError{ Code: CodeLockTimeout, Message: "Failed to acquire file lock", Suggestion: "Another dws process may be running. Wait for it to complete or check for stale lock files.", Operation: operation, Cause: err, } } // Network: DNS resolution failure if errContainsAny(msg, "no such host", "dns") { return &CLIError{ Code: CodeNetworkUnreachable, Message: "DNS resolution failed", Suggestion: "Check network connection and DNS settings", Operation: operation, Cause: err, } } // Network: TLS handshake if errContainsAny(msg, "tls handshake", "certificate", "x509") { return &CLIError{ Code: CodeNetworkUnreachable, Message: "TLS handshake failed", Suggestion: "Check system clock and CA certificates; corporate proxy may require custom CA", Operation: operation, Cause: err, } } // Network: connection refused / unreachable if errContainsAny(msg, "connection refused", "dial tcp") { return &CLIError{ Code: CodeNetworkUnreachable, Message: "Cannot connect to MCP server", Suggestion: "Check network connection or verify MCP server URL (dws auth status)", Operation: operation, Cause: err, } } // Network: timeout if errContainsAny(msg, "timeout", "deadline exceeded", "context deadline") { return &CLIError{ Code: CodeNetworkTimeout, Message: "Request timed out", Suggestion: "Retry later, or increase timeout with --timeout", Operation: operation, Cause: err, } } // Token verification failure if errContainsAny(msg, "token验证失败", "Token验证失败", "USER_TOKEN_ILLEGAL") { return &CLIError{ Code: CodeAuthTokenExpired, Message: "Token 已过期或验证失败", Suggestion: authExpiredSuggestion(), Operation: operation, Cause: err, } } // API key expired (e.g. apiKeyExpired from backend/gateway) if errContainsAny(msg, "apiKeyExpired", "api_key_expired", "ApiKeyExpired") { return &CLIError{ Code: CodeAuthTokenExpired, Message: "API Key 已过期", Suggestion: authExpiredSuggestion(), Operation: operation, Cause: err, } } // Not logged in if errContainsAny(msg, "Missing service_id or access_key") { return &CLIError{ Code: CodeAuthNotConfigured, Message: "当前未登录", Suggestion: notLoggedInSuggestion(), Operation: operation, Cause: err, } } // Permission denied if errContainsWord(msg, "403") || errContainsAny(msg, "forbidden", "permission") { suggestion := "Verify your account has permission for this resource" if errContainsAny(msg, "doc", "文档", "节点") { suggestion = "Contact document owner for access, or check if removed from collaborators" } else if errContainsAny(msg, "aitable", "table", "base", "record", "字段") { suggestion = "Run: dws aitable base list" } else if errContainsAny(msg, "chat", "群", "会话") { suggestion = "Verify you are a group member and the group still exists" } return &CLIError{ Code: CodeAuthPermission, Message: "Permission denied", Suggestion: suggestion, Operation: operation, Cause: err, } } // 服务端已生成结构化错误信息(含操作索引、回滚告知等), // 直接透传,不进入下方的模式分类(避免用固定文案覆盖服务端原始信息) if operation == "sheet/batch_update" { return &CLIError{ Code: CodeMCPServerError, Message: msg, Operation: operation, Cause: err, } } // 服务端返回的用户/成员参数校验错误(如“用户不存在”、“不属于当前组织”), // 文本含“不存在”但并非文档资源缺失,需在 RESOURCE_NOT_FOUND 分类之前拦截, // 透传服务端原始错误信息。 if errContainsAny(msg, "用户不存在", "不属于当前组织", "成员不存在") { errMsg := msg var body map[string]any if json.Unmarshal([]byte(msg), &body) == nil { errMsg = businessErrorDisplayMessage(body, msg) } return &CLIError{ Code: CodeMCPToolError, Message: errMsg, Suggestion: "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId", Operation: operation, Cause: err, } } // Resource not found if errContainsAny(msg, "not found", "不存在", "资源不存在") { suggestion := "Check if the resource exists or if your account has permission" code := CodeResourceNotFound if errContainsAny(msg, "table", "sheet", "dentry", "record", "base") { code = CodeTableNotFound suggestion = "Run: dws aitable doc search to verify BASE_ID, then sheet list for tables" } if errContainsAny(msg, "群不存在", "已解散", "被移出") { suggestion = "Group may have been disbanded or you were removed. Run: dws chat search" } if errContainsAny(msg, "文档", "doc", "节点") { suggestion = "Document may have been deleted or moved" } if errContainsAny(msg, "MCP不存在", "PARAM_ERROR") { return &CLIError{ Code: CodeMCPServerError, Message: msg, Suggestion: "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。", Operation: operation, Cause: err, } } return &CLIError{ Code: code, Message: "Requested resource not found", Suggestion: suggestion, Operation: operation, Cause: err, } } // JSON parse errors if errContainsAny(msg, "JSON 解析失败", "invalid character", "unexpected end of JSON input", "json:", "parsing tools/call response") { suggestion := "Check the JSON format of your input" if errContainsAny(msg, "add_base_record", "update_records") { suggestion = "Check --data JSON format, must be [{\"fields\":{...}}]" } if errContainsAny(msg, "fieldId", "字段") { suggestion += "; aitable record cells key must be fieldId, not field name. Run: dws aitable field get" } return &CLIError{ Code: CodeInvalidJSON, Message: "Invalid JSON format", Suggestion: suggestion, Operation: operation, Cause: err, } } // Server errors (5xx) if errContainsAny(msg, "HTTP 5", "internal server error") || errContainsWord(msg, "500") || errContainsWord(msg, "502") || errContainsWord(msg, "503") { return &CLIError{ Code: CodeMCPServerError, Message: "MCP server internal error", Suggestion: "Retry later. If persistent, contact admin", Operation: operation, Cause: err, } } // Operation failed (catch-all for server-side "操作失败") if errContainsAny(msg, "操作失败") { suggestion := "Use --verbose for detailed logs" if errContainsAny(msg, "字段", "field", "类型") { suggestion = "Run: dws aitable field get to verify field type and fieldId" } return &CLIError{ Code: CodeMCPToolError, Message: msg, Suggestion: suggestion, Operation: operation, Cause: err, } } // Generic tool/call failure if errContainsAny(msg, "tool", "调用失败") { return &CLIError{ Code: CodeMCPToolError, Message: msg, Suggestion: "Use --verbose for detailed logs", Operation: operation, Cause: err, } } // Check for known business error text patterns if suggestion := suggestForBusinessErrorText(msg); suggestion != "" { return &CLIError{ Code: CodeMCPToolError, Message: msg, Suggestion: suggestion, Operation: operation, Cause: err, } } return &CLIError{ Code: CodeUnclassified, Message: msg, Suggestion: "Use --verbose for detailed error logs", Operation: operation, Cause: err, } } // IsAuthError returns true if the error is a CLIError with an auth-related code. func IsAuthError(err error) bool { if cliErr, ok := err.(*CLIError); ok { return cliErr.Code == CodeAuthTokenExpired } return false } // suggestForBusinessErrorText returns a user-facing suggestion for known business // error text patterns, or "" if no specific suggestion applies. func suggestForBusinessErrorText(text string) string { switch { case strings.Contains(text, "搜索内容不能为空"): return "请提供非空搜索关键词: dws doc search --query \"关键词\"\n 若需浏览最近文档: dws doc list" case strings.Contains(text, "User has no permission to access this email"): return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list" case strings.Contains(text, "频率超限") || strings.Contains(text, "rate limit"): return "API rate limit exceeded, wait a moment and retry" case strings.Contains(text, "用户不存在") || strings.Contains(text, "不属于当前组织") || strings.Contains(text, "成员不存在"): return "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId" case strings.Contains(text, "未找到指定工具") || strings.Contains(text, "MCP不存在"): return "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。" case strings.Contains(text, "参数错误") || strings.Contains(text, "param error"): return "Check input parameters. Use --help for available flags" case strings.Contains(text, "无权限访问") || strings.Contains(text, "没有访问权限") || strings.Contains(text, "没有权限") || strings.Contains(text, "权限不足") || strings.Contains(text, "no permission") || strings.Contains(text, "permission denied") || strings.Contains(text, "FORBIDDEN"): return permissionDeniedSuggestion(text) default: return "" } } // documentPermissionServerCodes are permission-denied codes that are // demonstrably drive-specific (their identity carries the forbidden.* domain // prefix). Only these (plus the document/wiki role-threshold message wording) // justify the dws drive permission apply-* guidance. Generic code names such // as NO_PERMISSION and FORBIDDEN are deliberately excluded: attendance // (get-self-setting) and event-subscription tools have been observed returning // NO_PERMISSION, so keying guidance on it would mislead non-document products // exactly the way this classification tries to avoid. var documentPermissionServerCodes = map[string]bool{ "forbidden.no.auth": true, "forbidden.accessDenied": true, } // documentPermissionErrorText reports whether a permission-denied message is // specific to document/wiki permission (drive/doc/wiki), e.g. the role // threshold wording emitted by the member-management APIs or the document // permission error codes embedded in the message text. func documentPermissionErrorText(text string) bool { if text == "" { return false } lower := strings.ToLower(text) return strings.Contains(lower, "forbidden.no.auth") || strings.Contains(lower, "forbidden.accessdenied") || strings.Contains(text, "需要您具备") || strings.Contains(text, "及以上角色") } // isDocumentPermissionError reports whether a permission-denied body is // specific to document/wiki access, where applying for access via // dws drive permission apply-* is the actionable next step. func isDocumentPermissionError(body map[string]any) bool { for _, key := range []string{"code", "errorCode", "server_error_code"} { if code, ok := body[key].(string); ok && documentPermissionServerCodes[code] { return true } } return documentPermissionErrorText(businessErrorMessage(body)) } // genericPermissionSuggestion is the product-neutral suggestion for // permission-denied errors that are not specific to document/wiki access. const genericPermissionSuggestion = "Verify your account has permission for this resource" // permissionDeniedSuggestion picks between document apply-permission guidance // and a product-neutral hint: only document/wiki-specific errors point at // dws drive permission apply-*, so other products never get a misleading // document-permission suggestion. func permissionDeniedSuggestion(text string) string { if documentPermissionErrorText(text) { return permissionApplyGuidance } return genericPermissionSuggestion } // permissionDeniedSuggestionFor resolves the suggestion for a permission-denied // body: document/wiki-specific errors get the apply-permission guidance; other // products keep their product-specific suggestion (e.g. the mail mailbox hint) // or fall back to the product-neutral hint. func permissionDeniedSuggestionFor(body map[string]any) string { if isDocumentPermissionError(body) { return permissionApplyGuidance } if suggestion := suggestForBusinessError(body); suggestion != "" { return suggestion } return genericPermissionSuggestion } // ClassifyToolResultContent checks a raw MCP tool result content map for // DWS gateway auth errors and PAT permission error codes. This is used as the // edition.Hooks.ClassifyToolResult callback so the framework's runner returns // a typed error before its generic business-error classification. // // Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission > // no-permission guidance. func ClassifyToolResultContent(content map[string]any) error { if _, ok := getDWSGatewayErrorCode(content); ok { raw, _ := json.Marshal(content) return &CLIError{ Code: CodeAuthTokenExpired, Message: string(raw), Suggestion: authExpiredSuggestion(), } } for _, key := range []string{"code", "errorCode"} { if code, ok := content[key].(string); ok && patNoPermissionCodes[code] { return &PATError{RawJSON: cleanPATJSON(content, code)} } } // Permission-denied business errors: surface apply-permission guidance before // the framework's generic rendering swallows it. Guidance is limited to // document/wiki-specific signals (forbidden.* codes or role-threshold // wording); other products keep the product-neutral suggestion. if isNoPermissionError(content) { suggestion := permissionDeniedSuggestionFor(content) raw, _ := json.Marshal(content) return &CLIError{ Code: CodeAuthPermission, Message: businessErrorDisplayMessage(content, string(raw)), Suggestion: suggestion, } } return nil } // patNoPermissionCodes are PAT error codes that should be passed through. var patNoPermissionCodes = map[string]bool{ "PAT_NO_PERMISSION": true, "PAT_LOW_RISK_NO_PERMISSION": true, "PAT_MEDIUM_RISK_NO_PERMISSION": true, "PAT_HIGH_RISK_NO_PERMISSION": true, } // noPermissionServerCodes are business-level permission-denied codes (from the // MCP server / gateway) that should surface apply-permission guidance instead of // being swallowed by the framework's generic business-error rendering. var noPermissionServerCodes = map[string]bool{ "NO_PERMISSION": true, "FORBIDDEN": true, "forbidden.no.auth": true, "forbidden.accessDenied": true, } // permissionApplySteps lists the two-step commands to apply for document access. const permissionApplySteps = " 1) 查可申请角色与审批人: dws drive permission apply-info --node <节点ID或URL>\n" + " 2) 选定角色与审批人后发起申请: dws drive permission apply --node <节点ID或URL> --role --users <审批人userId>" // permissionApplyGuidance is the standard suggestion for permission-denied errors. const permissionApplyGuidance = "若你对该文档/知识库暂无访问权限,可发起权限申请:\n" + permissionApplySteps // ClassifyMCPResponseText classifies a text response returned by an MCP tool call. // Returns a typed error for known gateway auth failures, PAT interceptions, // and business-level errors embedded in HTTP-200 JSON bodies. // // Check order matters: DWS gateway > PAT permission > generic business error. func ClassifyMCPResponseText(text string) error { var body map[string]any if json.Unmarshal([]byte(text), &body) != nil { return nil } if _, ok := getDWSGatewayErrorCode(body); ok { return &CLIError{ Code: CodeAuthTokenExpired, Message: text, Suggestion: authExpiredSuggestion(), } } if isNotLoggedInError(body) { return &CLIError{ Code: CodeAuthNotConfigured, Message: "当前未登录", Suggestion: notLoggedInSuggestion(), } } for _, key := range []string{"code", "errorCode"} { if code, ok := body[key].(string); ok && patNoPermissionCodes[code] { return &PATError{RawJSON: cleanPATJSON(body, code)} } } if isNoPermissionError(body) { return &CLIError{ Code: CodeAuthPermission, Message: text, Suggestion: permissionDeniedSuggestionFor(body), } } if isBusinessError(body) { return &CLIError{ Code: CodeMCPToolError, Message: businessErrorDisplayMessage(body, text), Suggestion: suggestForBusinessError(body), } } return nil } var patTopLevelStrip = map[string]bool{ "success": true, "code": true, "errorCode": true, "error_code": true, "message": true, "error": true, "trace_id": true, "class": true, } func cleanPATJSON(body map[string]any, code string) string { out := map[string]any{ "success": false, "code": code, } if data, ok := body["data"]; ok { out["data"] = stripClassFields(data) } else { fallback := map[string]any{} for k, v := range body { if !patTopLevelStrip[k] { fallback[k] = v } } if len(fallback) > 0 { out["data"] = stripClassFields(fallback) } } b, err := json.MarshalIndent(out, "", " ") if err != nil { return fmt.Sprintf(`{"success":false,"code":"%s"}`, code) } return string(b) } func stripClassFields(v any) any { switch val := v.(type) { case map[string]any: clean := make(map[string]any, len(val)) for k, item := range val { if k == "class" { continue } clean[k] = stripClassFields(item) } return clean case []any: clean := make([]any, len(val)) for i, item := range val { clean[i] = stripClassFields(item) } return clean default: return v } } func errContainsAny(s string, substrs ...string) bool { lower := strings.ToLower(s) for _, sub := range substrs { if strings.Contains(lower, strings.ToLower(sub)) { return true } } return false } func errContainsWord(s string, substr string) bool { lower := strings.ToLower(s) sub := strings.ToLower(substr) for i := 0; i < len(lower); { idx := strings.Index(lower[i:], sub) if idx < 0 { return false } pos := i + idx leftOK := pos == 0 || !isAlnum(lower[pos-1]) rightOK := pos+len(sub) >= len(lower) || !isAlnum(lower[pos+len(sub)]) if leftOK && rightOK { return true } i = pos + 1 } return false } func isAlnum(c byte) bool { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }