// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compat import ( "encoding/json" "fmt" "io" "os" "strconv" "strings" "time" "unicode/utf8" "gopkg.in/yaml.v3" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) // ApplyTransform applies a named transform rule to a value. // Supported transforms: iso8601_to_millis, csv_to_array, json_parse, // json_parse_strict, enum_map, file_read, invert_bool, parse_bool, string_to_int64. func ApplyTransform(value any, transform string, args map[string]any) (any, error) { switch strings.TrimSpace(transform) { case "": return value, nil case "iso8601_to_millis": return transformISO8601ToMillis(value) case "csv_to_array": return transformCSVToArray(value) case "json_parse": return transformJSONParse(value) case "json_parse_strict": return transformJSONParseStrict(value) case "enum_map": return transformEnumMap(value, args) case "file_read": return transformFileRead(value) case "invert_bool": return transformInvertBool(value) case "parse_bool": return transformParseBool(value) case "attendance_class_check_time": return transformAttendanceClassCheckTime(value) case "string_to_int64": return transformStringToInt64(value) default: return value, nil } } // transformInvertBool flips a boolean: true → false, false → true. Strings // "true"/"false" (any case) are accepted. Used by envelope flags whose CLI // surface and MCP body have opposite semantics — e.g. `--off` (CLI) maps to // `mute=true` (MCP) for "mute is enabled", so the flag override declares // `transform: invert_bool` and the framework flips at send time. func transformInvertBool(value any) (any, error) { switch v := value.(type) { case bool: return !v, nil case string: s := strings.ToLower(strings.TrimSpace(v)) switch s { case "true", "1", "yes", "on": return false, nil case "false", "0", "no", "off", "": return true, nil } return value, nil default: return value, nil } } // transformParseBool coerces a CLI string flag into a real JSON boolean so the // MCP body carries `false`/`true` (not the string "false"/"true" or a swallowed // zero value). Used by envelope flags that are semantically boolean but must be // declared as string flags to accept an explicit `false` on the command line // (cobra bool flags drop the space-form value). Unknown tokens pass through // unchanged so upstream validators own the error wording. func transformParseBool(value any) (any, error) { switch v := value.(type) { case bool: return v, nil case string: switch strings.ToLower(strings.TrimSpace(v)) { case "true", "1", "yes", "on": return true, nil case "false", "0", "no", "off": return false, nil } return value, nil default: return value, nil } } // transformAttendanceClassCheckTime parses a class-VO JSON string and converts // every "HH:mm" checkTime under sections[*].times[*] and // setting.topRestTimeList[*] into a Unix-millis number (1970-01-01 HH:mm in // UTC+8), mirroring wukong's convertClassCheckTime. The MCP backend expects the // numeric form; the envelope cannot express this nested walk, so it lives here. func transformAttendanceClassCheckTime(value any) (any, error) { parsed, err := transformJSONParseStrict(value) if err != nil { return nil, err } classVO, ok := parsed.(map[string]any) if !ok { return parsed, nil } cst := time.FixedZone("CST", 8*3600) convertOne := func(obj map[string]any) { if ct, ok := obj["checkTime"].(string); ok { ct = strings.TrimSpace(ct) if t, err := time.ParseInLocation("2006-01-02 15:04", "1970-01-01 "+ct, cst); err == nil { obj["checkTime"] = float64(t.UnixMilli()) } } } if sections, ok := classVO["sections"].([]any); ok { for _, sec := range sections { if secMap, ok := sec.(map[string]any); ok { if times, ok := secMap["times"].([]any); ok { for _, t := range times { if tMap, ok := t.(map[string]any); ok { convertOne(tMap) } } } } } } if setting, ok := classVO["setting"].(map[string]any); ok { if restList, ok := setting["topRestTimeList"].([]any); ok { for _, item := range restList { if itemMap, ok := item.(map[string]any); ok { convertOne(itemMap) } } } } return classVO, nil } func transformISO8601ToMillis(value any) (any, error) { s, ok := toString(value) if !ok { return value, nil } s = strings.TrimSpace(s) if s == "" { return value, nil } // Try direct millisecond integer first. if millis, err := strconv.ParseInt(s, 10, 64); err == nil && millis > 1_000_000_000_000 { return millis, nil } layouts := []struct { layout string location *time.Location }{ {layout: time.RFC3339}, {layout: "2006-01-02T15:04:05"}, {layout: "2006-01-02 15:04:05"}, {layout: "2006-01-02", location: time.UTC}, } for _, candidate := range layouts { var ( parsed time.Time err error ) if candidate.location != nil { parsed, err = time.ParseInLocation(candidate.layout, s, candidate.location) } else { parsed, err = time.Parse(candidate.layout, s) } if err == nil { return parsed.UnixMilli(), nil } } return nil, apperrors.NewValidation(fmt.Sprintf("iso8601_to_millis: cannot parse %q as ISO-8601", s)) } func transformCSVToArray(value any) (any, error) { s, ok := toString(value) if !ok { // If it's already a slice, pass through. return value, nil } s = strings.TrimSpace(s) if s == "" { return []any{}, nil } // If already looks like a JSON array, try parsing it. if strings.HasPrefix(s, "[") { var arr []any if err := json.Unmarshal([]byte(s), &arr); err == nil { return arr, nil } } parts := strings.Split(s, ",") result := make([]any, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { result = append(result, trimmed) } } return result, nil } // transformJSONParse parses a CLI string into a structured value so callers can // pass complex payloads (JSON arrays/objects) through a single flag. // // Two input dialects are accepted, in order: // 1. Strict JSON — `[{"fieldName":"x","type":"text"}]` // 2. YAML (flow) — `[{fieldName: x, type: text}]` // // YAML is a superset of JSON that permits unquoted keys and strings, which // dramatically reduces the need for shell-level escaping. Users can therefore // write `--fields '[{fieldName: 标题, type: text}]'` instead of piling quotes // around every token. The output shape is the same either way; downstream // consumers see the parsed Go value, not the original dialect. func transformJSONParse(value any) (any, error) { s, ok := toString(value) if !ok { return value, nil } s = strings.TrimSpace(s) if s == "" { return value, nil } // @file / @- expansion — read the JSON/YAML payload from a file or stdin // before parsing. A leading "@" is an unambiguous file sentinel because a // JSON/YAML value never starts with "@"; this is what the error hint below // promises and lets long/complex payloads (many records, big cell ranges) // avoid shell-quoting hell. s, err := resolveJSONSource(s) if err != nil { return nil, err } if s == "" { return value, nil } // Strict JSON first — fast path and unambiguous type promotion (numbers // stay numbers, etc.). var parsed any if err := json.Unmarshal([]byte(s), &parsed); err == nil { return parsed, nil } // YAML (flow) fallback — accepts `{key: value}` without surrounding // quotes, which is the natural form when typing at a shell prompt. if err := yaml.Unmarshal([]byte(s), &parsed); err == nil { return parsed, nil } return nil, apperrors.NewValidation( "json_parse: input is not valid JSON or YAML; " + "quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " + "or pass `@path/to/file.json` (or `@-` for stdin) to read from a file", ) } // resolveJSONSource expands an @file / @- reference used by the json_parse // transforms. A leading "@" is the file sentinel: "@-" reads stdin, "@" // reads the file (UTF-8, via transformFileRead). Any value not starting with // "@" is returned unchanged. JSON/YAML payloads never start with "@", so this // is unambiguous for structured flags. func resolveJSONSource(s string) (string, error) { if !strings.HasPrefix(s, "@") { return s, nil } ref := strings.TrimSpace(s[1:]) if ref == "" { return "", apperrors.NewValidation( "json_parse: `@` must be followed by a file path, or `@-` to read from stdin") } out, err := transformFileRead(ref) if err != nil { return "", err } loaded, _ := out.(string) return strings.TrimSpace(loaded), nil } // transformJSONParseStrict is the strict variant of json_parse: only accepts // well-formed JSON, rejecting input that the YAML fallback would otherwise // silently coerce to a scalar string. Use when the upstream tool requires a // structured array/object value and "garbage in → empty out" is unacceptable. func transformJSONParseStrict(value any) (any, error) { s, ok := toString(value) if !ok { return value, nil } s = strings.TrimSpace(s) if s == "" { return value, nil } // @file / @- expansion — same sentinel as json_parse (see resolveJSONSource). s, err := resolveJSONSource(s) if err != nil { return nil, err } if s == "" { return value, nil } var parsed any if err := json.Unmarshal([]byte(s), &parsed); err != nil { return nil, apperrors.NewValidation( "json_parse_strict: input is not valid JSON; " + "this transform rejects YAML-style ad-hoc input — quote the whole value " + "as strict JSON (e.g. '[{\"key\":\"value\"}]'), pass `@path/to/file.json` " + "(or `@-` for stdin), or use `json_parse` for YAML-tolerant parsing", ) } return parsed, nil } func transformEnumMap(value any, args map[string]any) (any, error) { s, ok := toString(value) if !ok { s = fmt.Sprint(value) } s = strings.TrimSpace(s) if mapped, exists := args[s]; exists { return mapped, nil } if defaultVal, exists := args["_default"]; exists { return defaultVal, nil } return value, nil } // transformFileRead reads the file at the given path and returns its contents // as a UTF-8 string. The special path "-" reads from stdin. // // Typical envelope use is paired with CLIFlagOverride.MapsTo so a path-typed // CLI flag (e.g. --content-file ./a.md) routes the file contents into a // content-typed MCP parameter (e.g. markdown), letting a sibling literal // flag (--content "# 标题") feed the same parameter without conflict. // // Errors are surfaced as validation errors so the dispatcher returns exit code 2 // (user input) rather than the generic exit code 1 (transient failure). func transformFileRead(value any) (any, error) { s, ok := toString(value) if !ok { return nil, apperrors.NewValidation("file_read: expected string path, got non-string value") } s = strings.TrimSpace(s) if s == "" { return nil, apperrors.NewValidation("file_read: empty path") } var buf []byte var err error if s == "-" { buf, err = io.ReadAll(os.Stdin) if err != nil { return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read stdin: %v", err)) } } else { buf, err = os.ReadFile(s) if err != nil { return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read %q: %v", s, err)) } } if !utf8.Valid(buf) { return nil, apperrors.NewValidation(fmt.Sprintf("file_read: %q is not valid UTF-8", s)) } return string(buf), nil } // transformStringToInt64 parses a string-form integer (e.g. "12345") into an // int64 so the MCP body carries a numeric value rather than a quoted string. // Used for envelope flags whose upstream schema requires int64 (e.g. deptId). // // Two ergonomic guards are layered on top of the raw parse: // // 1. Placeholder rejection — common LLM/AI-agent placeholders for "myself" / // "root department" (self / me / 我 / root / 0) are NOT valid deptIds. The // dingtalk root department's deptId is the literal integer 1; if we let // "self" fall through to the MCP, the server returns an empty result with // success=true, masking the mistake. Instead, return a validation error // pointing the caller at the correct usage. Mirrors wukong's cmdutil error // wording ("根部门 deptId=1,请使用 --id 1") so CLI and wukong agree. // // 2. Non-numeric rejection — anything else that fails strconv.ParseInt is // reported as a validation error rather than silently sent as a string, // which the upstream server would also reject (or worse: coerce to 0). // // Numeric int / int64 inputs pass through unchanged; the transform is a no-op // when the schema-typed flag already produced an integer. func transformStringToInt64(value any) (any, error) { switch v := value.(type) { case nil: return value, nil case int: return int64(v), nil case int32: return int64(v), nil case int64: return v, nil case float64: // JSON numbers decode as float64; accept only when integer-valued. if v == float64(int64(v)) { return int64(v), nil } return nil, apperrors.NewValidation(fmt.Sprintf("string_to_int64: %v is not an integer", v)) } s, ok := toString(value) if !ok { return value, nil } s = strings.TrimSpace(s) if s == "" { return value, nil } // Placeholder guard: LLMs often invent symbolic values like "self" / "me" / // "root" / "我" for "the current user's root department". Catch them with a // clear error pointing at the canonical deptId=1, instead of forwarding the // bogus value and letting the upstream return success=true with empty data. lowered := strings.ToLower(s) switch lowered { case "self", "me", "我", "root", "0": return nil, apperrors.NewValidation( "flag --id 必须是整数;钉钉根部门 deptId=1,请使用 --id 1", ) } n, err := strconv.ParseInt(s, 10, 64) if err != nil { return nil, apperrors.NewValidation(fmt.Sprintf("flag --id 必须是整数,got %q", s)) } return n, nil } func toString(v any) (string, bool) { switch val := v.(type) { case string: return val, true case fmt.Stringer: return val.String(), true default: return "", false } }