Files
dingtalk-workspace-cli/internal/helpers/doc_jsonml.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

283 lines
8.1 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 helpers
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers/docjsonml"
jsonrepair "github.com/RealAlexandreAI/json-repair"
"github.com/spf13/cobra"
)
var docDangerousUnicode = [...]rune{
0x200B,
0x200C,
0x200E,
0x200F,
0x202A,
0x202B,
0x202C,
0x202D,
0x202E,
0x2066,
0x2067,
0x2068,
0x2069,
0xFEFF,
0x00AD,
}
var docDangerousSet = func() map[rune]bool {
m := make(map[rune]bool, len(docDangerousUnicode))
for _, r := range docDangerousUnicode {
m[r] = true
}
return m
}()
func stripDocDangerousUnicode(s string) string {
return strings.Map(func(r rune) rune {
if docDangerousSet[r] {
return -1
}
return r
}, s)
}
type docJSONMLFixMode int
const (
docJSONMLFixDefault docJSONMLFixMode = iota
docJSONMLFixFull
docJSONMLFixNone
)
func docResolveFixMode(cmd *cobra.Command) docJSONMLFixMode {
noFix, _ := cmd.Flags().GetBool("no-fix-jsonml")
fix, _ := cmd.Flags().GetBool("fix-jsonml")
if noFix && fix {
fmt.Fprintln(cmd.ErrOrStderr(), "[WARN] --fix-jsonml 和 --no-fix-jsonml 同时传入,以 --no-fix-jsonml 为准(全部修复关闭)")
return docJSONMLFixNone
}
if noFix {
return docJSONMLFixNone
}
if fix {
return docJSONMLFixFull
}
return docJSONMLFixDefault
}
func docCoerceJSONMLBodyShape(raw string) (string, []string, error) {
if raw == "" {
return raw, nil, nil
}
var probe any
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
return raw, nil, nil
}
if _, ok := probe.([]any); ok {
wrapped, err := json.Marshal(map[string]any{"jsonml": probe})
if err != nil {
return raw, nil, fmt.Errorf("wrap bare jsonml array: %w", err)
}
return string(wrapped), nil, nil
}
return raw, nil, nil
}
func docCoerceJSONMLNodeShape(raw string) (string, []string, error) {
if raw == "" {
return raw, nil, nil
}
var probe any
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
return raw, nil, nil
}
if _, ok := probe.([]any); ok {
return raw, nil, nil
}
wrapper, ok := probe.(map[string]any)
if !ok {
return raw, nil, nil
}
inner, hasKey := wrapper["jsonml"]
if !hasKey {
return raw, nil, nil
}
arr, ok := inner.([]any)
if !ok {
return raw, nil, nil
}
switch len(arr) {
case 0:
return "", nil, fmt.Errorf(`--content-format jsonml 输入 {"jsonml":[]}: wrapper 中 jsonml 数组为空`)
case 1:
out, err := json.Marshal(arr[0])
if err != nil {
return raw, nil, fmt.Errorf("unwrap single jsonml node: %w", err)
}
return string(out), []string{`输入为 {"jsonml":[node]} body 形态,已自动解包为单节点以符合 block 命令协议`}, nil
default:
return "", nil, fmt.Errorf(`block insert/update 一次只能处理一个 JSONML 节点,输入 {"jsonml":[...]} 包含 %d 个节点。请分多次调用,或使用 doc update --content-format jsonml 整篇覆盖`, len(arr))
}
}
func prepareDocJSONMLBody(cmd *cobra.Command, raw string) (string, error) {
mode := docResolveFixMode(cmd)
coerced, coerceNotes, err := docCoerceJSONMLBodyShape(raw)
if err != nil {
return "", err
}
docEmitJSONMLFixNotes(cmd, coerceNotes)
raw = coerced
var wrapper map[string]any
if err := json.Unmarshal([]byte(raw), &wrapper); err != nil {
if mode == docJSONMLFixFull {
repaired, repairErr := jsonrepair.RepairJSON(raw)
if repairErr != nil {
return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err)
}
fmt.Fprintln(cmd.ErrOrStderr(), "[FIX] JSON 语法已自动修复(括号/逗号等结构性错误)")
if err2 := json.Unmarshal([]byte(repaired), &wrapper); err2 != nil {
return "", fmt.Errorf("JSON 修复后仍无法解析: %w", err2)
}
} else {
return "", fmt.Errorf("JSON 语法错误: %w\n输入不是有效的 JSON(可能缺少括号或逗号)。如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err)
}
}
bodyAny, ok := wrapper["jsonml"]
if !ok {
return "", fmt.Errorf(`--content-format jsonml 输入 JSON 必须包含 "jsonml" 字段,格式: {"jsonml": [...]}`)
}
bodyArr, ok := bodyAny.([]any)
if !ok {
return "", fmt.Errorf(`--content-format jsonml 字段 "jsonml" 必须是数组`)
}
if mode != docJSONMLFixNone {
fixed, notes := docjsonml.NormalizeJsonMLBody(bodyArr)
bodyArr = fixed
docEmitJSONMLFixNotes(cmd, notes)
wrapped, wrapNotes := docjsonml.EnsureRootWrappedBody(bodyArr)
bodyArr = wrapped
docEmitJSONMLFixNotes(cmd, wrapNotes)
}
vr := docjsonml.ValidateJsonMLBodyV2(bodyArr)
if vr.HasErrors() {
return "", fmt.Errorf("JSONML 格式校验失败:\n%s\n请确认输入格式是否正确,或通过 --no-fix-jsonml 关闭自动修复以排查原始错误", vr.Summary())
}
if summary := vr.Summary(); summary != "" {
fmt.Fprintln(cmd.ErrOrStderr(), "[WARN] "+summary)
}
out, err := json.Marshal(bodyArr)
if err != nil {
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
}
return stripDocDangerousUnicode(string(out)), nil
}
func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error) {
if rawElement == "" {
return "", fmt.Errorf("--content-format jsonml 要求通过 --element 提供 JSONML 数组")
}
mode := docResolveFixMode(cmd)
coerced, coerceNotes, err := docCoerceJSONMLNodeShape(rawElement)
if err != nil {
return "", err
}
docEmitJSONMLFixNotes(cmd, coerceNotes)
rawElement = coerced
var node any
if err := json.Unmarshal([]byte(rawElement), &node); err != nil {
if mode == docJSONMLFixFull {
repaired, repairErr := jsonrepair.RepairJSON(rawElement)
if repairErr != nil {
return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err)
}
fmt.Fprintln(cmd.ErrOrStderr(), "[FIX] JSON 语法已自动修复(括号/逗号等结构性错误)")
if err2 := json.Unmarshal([]byte(repaired), &node); err2 != nil {
return "", fmt.Errorf("JSON 修复后仍无法解析: %w", err2)
}
} else {
return "", fmt.Errorf("JSON 语法错误: %w\n输入不是有效的 JSON(可能缺少括号或逗号)。如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err)
}
}
if _, ok := node.([]any); !ok {
return "", fmt.Errorf("--content-format jsonml 要求 --element 为 JSON 数组,实际类型: %T", node)
}
if mode != docJSONMLFixNone {
fixed, notes := docjsonml.NormalizeJsonMLNode(node)
node = fixed
docEmitJSONMLFixNotes(cmd, notes)
}
vr := docjsonml.ValidateJsonMLNodeV2(node)
if vr.HasErrors() {
return "", fmt.Errorf("JSONML 格式校验失败:\n%s\n请确认输入格式是否正确,或通过 --no-fix-jsonml 关闭自动修复以排查原始错误", vr.Summary())
}
if summary := vr.Summary(); summary != "" {
fmt.Fprintln(cmd.ErrOrStderr(), "[WARN] "+summary)
}
out, err := json.Marshal(node)
if err != nil {
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
}
return string(out), nil
}
func docEmitJSONMLFixNotes(cmd *cobra.Command, notes []string) {
if len(notes) == 0 {
return
}
fmt.Fprintf(cmd.ErrOrStderr(), "[FIX] JSONML 自动修复(%d 项):\n", len(notes))
for i, n := range notes {
fmt.Fprintf(cmd.ErrOrStderr(), " %d. %s\n", i+1, n)
}
}
func sniffJsonMLLike(content string) bool {
const lookahead = 64
s := strings.TrimLeft(content, " \t\r\n")
if s == "" {
return false
}
scan := s
if len(scan) > lookahead {
scan = scan[:lookahead]
}
switch s[0] {
case '[':
rest := strings.TrimLeft(scan[1:], " \t\r\n")
return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `[`)
case '{':
rest := strings.TrimLeft(scan[1:], " \t\r\n")
return strings.HasPrefix(rest, `"jsonml"`)
}
return false
}