`dws report entry submit --contents-file <f>` (and `--contents -` stdin) silently submitted `contents:[null]`. Root cause: the `--contents-file` flag had no transform, so its value mapped to an unused param while `--contents` stayed empty. - file_read_json transform + a build-time report hook resolve --contents-file / --contents - / @file natively in Go (priority: file > stdin > inline) and declare a contents / contents-file one-of group so a file-only invocation is no longer rejected at parse time. - generalizes to @file / @- input for any structured JSON-array flag.
458 lines
14 KiB
Go
458 lines
14 KiB
Go
// 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, "@<path>"
|
|
// 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
|
|
}
|
|
}
|