550 lines
16 KiB
Go
550 lines
16 KiB
Go
package helpers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
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
|
|
}
|
|
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, "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, "MCP不存在"):
|
|
return "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。"
|
|
case strings.Contains(text, "参数错误") || strings.Contains(text, "param error"):
|
|
return "Check input parameters. Use --help for available flags"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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)}
|
|
}
|
|
}
|
|
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,
|
|
}
|
|
|
|
// 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 isBusinessError(body) {
|
|
return &CLIError{
|
|
Code: CodeMCPToolError,
|
|
Message: 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')
|
|
}
|