Files
dingtalk-workspace-cli/internal/compat/transform.go
T
539d10f80f feat: 合并 pre-mcp-discovery 分支能力到 main(服务发现保持生产) (#391)
将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main,
服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。

主要内容:
- 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355)
- DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390)
- envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化
- skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等)
- product/oa 进一步对齐 wukong

排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com);
以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。

Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com>
Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
2026-06-02 21:04:12 +08:00

340 lines
10 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, 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 "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
}
}
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
}
// 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` to read from a file",
)
}
// 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
}
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\"}]') 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
}
}