将 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>
397 lines
15 KiB
Go
397 lines
15 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/cobracmd"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/asynctask"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
// PR-D:aitable export/import 静态命令覆盖
|
||
//
|
||
// MSE 已经注册了 export_data / import_data / prepare_import_upload 三个 tool
|
||
// (动态发现命令)。本文件用 preferLegacyLeaf 固定命令 surface,避免动态
|
||
// 发现层和 Wukong 的稳定命令口径漂移。
|
||
//
|
||
// TRANSITIONAL: 等 mse 把 asyncBehavior 标注加入 toolOverrides 后,
|
||
// 这套 helper 行为可由动态发现层统一处理,本文件可整体删除。
|
||
// 工单:plan/mse-yuyuan-patch.md(待后续补充 asyncBehavior 规范)
|
||
|
||
// ── aitable export data ─────────────────────────────────────
|
||
|
||
func newAitableExportDataCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "data",
|
||
Short: i18n.T("导出数据"),
|
||
Long: i18n.T(`导出 AI 表格数据的统一入口。
|
||
不传 --task-id 时,根据 --scope / --format 创建新的导出任务,并同步等待结果;
|
||
若在等待窗口内完成,则直接返回 downloadUrl 和 fileName。
|
||
传入 --task-id 时,继续等待该任务,不会重新创建。
|
||
|
||
scope 可选值:all(整个 Base)、table(指定数据表)、view(指定视图)。
|
||
format 可选值:excel、attachment、excel_and_attachment、excel_with_inline_images。`),
|
||
Example: ` dws aitable export data --base-id BASE_ID --scope all --format excel
|
||
dws aitable export data --base-id BASE_ID --scope table --table-id TABLE_ID --format excel
|
||
dws aitable export data --base-id BASE_ID --scope view --table-id TABLE_ID --view-id VIEW_ID --format excel
|
||
dws aitable export data --base-id BASE_ID --task-id TASK_ID
|
||
# 查询 baseId: dws aitable base list`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return runAitableExportData(cmd, runner)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||
addAitableHiddenStringFlag(cmd, "base", "--base-id 的兼容别名")
|
||
cmd.Flags().String("scope", "", i18n.T("导出范围:all(整个 Base)、table(指定数据表)、view(指定视图)"))
|
||
cmd.Flags().String("format", "", i18n.T("导出格式:excel、attachment、excel_and_attachment、excel_with_inline_images"))
|
||
cmd.Flags().String("task-id", "", i18n.T("已有导出任务 ID,传入后继续等待(忽略 scope/format/table-id/view-id)"))
|
||
cmd.Flags().String("table-id", "", i18n.T("Table ID,scope=table 或 scope=view 时必填"))
|
||
cmd.Flags().String("view-id", "", i18n.T("View ID,scope=view 时必填"))
|
||
cmd.Flags().Int("timeout-ms", 0, i18n.T("单次等待超时(毫秒),默认 30000,最大 30000"))
|
||
return cmd
|
||
}
|
||
|
||
func runAitableExportData(cmd *cobra.Command, runner executor.Runner) error {
|
||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
taskID, _ := cmd.Flags().GetString("task-id")
|
||
scope, _ := cmd.Flags().GetString("scope")
|
||
format, _ := cmd.Flags().GetString("format")
|
||
tableID, _ := cmd.Flags().GetString("table-id")
|
||
viewID, _ := cmd.Flags().GetString("view-id")
|
||
timeoutMS, _ := cmd.Flags().GetInt("timeout-ms")
|
||
params := map[string]any{
|
||
"baseId": baseID,
|
||
}
|
||
if taskID != "" {
|
||
params["taskId"] = taskID
|
||
} else {
|
||
if strings.TrimSpace(scope) == "" {
|
||
return apperrors.NewValidation("--scope is required")
|
||
}
|
||
if strings.TrimSpace(format) == "" {
|
||
return apperrors.NewValidation("--format is required")
|
||
}
|
||
params["scope"] = scope
|
||
params["format"] = format
|
||
}
|
||
if tableID != "" {
|
||
params["tableId"] = tableID
|
||
}
|
||
if viewID != "" {
|
||
params["viewId"] = viewID
|
||
}
|
||
if timeoutMS > 0 {
|
||
params["timeoutMs"] = timeoutMS
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "aitable", "export_data", params,
|
||
))
|
||
}
|
||
return runAitableTool(cmd, runner, "export_data", params)
|
||
}
|
||
|
||
// ── aitable import upload ───────────────────────────────────
|
||
|
||
func newAitableImportUploadCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "upload",
|
||
Short: i18n.T("准备导入文件上传"),
|
||
Long: i18n.T(`为导入任务申请 OSS 直传地址。返回 uploadUrl 和 importId。
|
||
客户端应通过 HTTP PUT 将原始文件字节流上传至 uploadUrl。
|
||
上传完成后将 importId 传入 import data 即可触发导入。
|
||
|
||
完整流程:
|
||
1. dws aitable import upload --base-id BASE_ID --file-name data.xlsx --file-size 204800
|
||
→ 获取 uploadUrl 和 importId
|
||
2. curl -X PUT "<uploadUrl>" --data-binary @data.xlsx
|
||
→ 上传文件到 OSS
|
||
3. dws aitable import data --import-id <importId>
|
||
→ 触发导入`),
|
||
Example: ` dws aitable import upload --base-id BASE_ID --file-name data.xlsx --file-size 204800
|
||
# 查询 baseId: dws aitable base list`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return runAitableImportUpload(cmd, runner)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||
addAitableHiddenStringFlag(cmd, "base", "--base-id 的兼容别名")
|
||
cmd.Flags().String("file-name", "", i18n.T("文件名,须带扩展名,如 data.xlsx (必填)"))
|
||
cmd.Flags().Int64("file-size", 0, i18n.T("文件大小(字节数)(必填)"))
|
||
return cmd
|
||
}
|
||
|
||
func runAitableImportUpload(cmd *cobra.Command, runner executor.Runner) error {
|
||
fileName, err := aitableRequiredFlag(cmd, "file-name")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fileSize, _ := cmd.Flags().GetInt64("file-size")
|
||
params := map[string]any{
|
||
"baseId": baseID,
|
||
"fileName": fileName,
|
||
}
|
||
if fileSize > 0 {
|
||
params["fileSize"] = fileSize
|
||
}
|
||
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "aitable", "prepare_import_upload", params,
|
||
))
|
||
}
|
||
return runAitableTool(cmd, runner, "prepare_import_upload", params)
|
||
}
|
||
|
||
// ── aitable import data ─────────────────────────────────────
|
||
|
||
func newAitableImportDataCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "data",
|
||
Short: i18n.T("导入数据"),
|
||
Long: i18n.T(`将已通过 import upload 上传完成的文件导入 AI 表格。
|
||
支持两种模式:
|
||
1. 新建表导入(默认):不传 --table-id,每个 Sheet 会新建为独立的数据表
|
||
2. 追加导入:传入 --table-id,数据将作为新行追加到该已有表中
|
||
|
||
工具内部会等待导入完成,大多数情况下一次调用即可拿到最终结果。
|
||
若在 timeout 内未完成,再次传入相同 importId 继续等待,无需重新提交任务。
|
||
|
||
追加导入时的注意事项:
|
||
- 系统按列名自动匹配字段,源文件列名须与目标表字段名一致
|
||
- 若需自定义映射关系,使用 --field-mapping 指定(key=目标表字段名,value=源文件列名)
|
||
- 多 Sheet 文件默认使用第一个 Sheet,可通过 --src-sheet-name 指定`),
|
||
Example: ` # 新建表导入
|
||
dws aitable import data --import-id IMPORT_ID
|
||
# 追加到已有表
|
||
dws aitable import data --import-id IMPORT_ID --table-id TABLE_ID
|
||
# 指定表头行和源 Sheet
|
||
dws aitable import data --import-id IMPORT_ID --table-id TABLE_ID --header-row 2 --src-sheet-name "Sheet1"`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return runAitableImportData(cmd, runner)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("import-id", "", i18n.T("prepare_import_upload 返回的 importId (必填)"))
|
||
cmd.Flags().String("table-id", "", i18n.T("目标数据表 ID。传入后数据将作为新行追加到该表中;不传则默认新建表导入"))
|
||
cmd.Flags().Int("timeout", 0, i18n.T("最长等待时间(秒),默认且推荐使用最大值 30"))
|
||
cmd.Flags().Int("header-row", 0, i18n.T("表头所在行号(从 1 开始),数据从 headerRow 的下一行开始读取。不传则自动识别表头行"))
|
||
cmd.Flags().String("src-sheet-name", "", i18n.T("源文件中的 Sheet 名称。多 Sheet 文件时指定从哪个 Sheet 导入数据。不传则默认使用第一个 Sheet"))
|
||
cmd.Flags().String("field-mapping", "", i18n.T("字段映射关系 JSON 对象。key 为目标表的字段名,value 为源文件中的列名。不传则按列名自动匹配"))
|
||
return cmd
|
||
}
|
||
|
||
func runAitableImportData(cmd *cobra.Command, runner executor.Runner) error {
|
||
importID, err := aitableRequiredFlag(cmd, "import-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tableID, _ := cmd.Flags().GetString("table-id")
|
||
fieldMapping, _ := cmd.Flags().GetString("field-mapping")
|
||
headerRow, _ := cmd.Flags().GetInt("header-row")
|
||
srcSheetName, _ := cmd.Flags().GetString("src-sheet-name")
|
||
timeoutSec, _ := cmd.Flags().GetInt("timeout")
|
||
|
||
importParams := map[string]any{
|
||
"importId": importID,
|
||
}
|
||
if tableID != "" {
|
||
importParams["tableId"] = tableID
|
||
}
|
||
if timeoutSec > 0 {
|
||
importParams["timeout"] = timeoutSec
|
||
}
|
||
if headerRow > 0 {
|
||
importParams["headerRow"] = headerRow
|
||
}
|
||
if strings.TrimSpace(srcSheetName) != "" {
|
||
importParams["srcSheetName"] = strings.TrimSpace(srcSheetName)
|
||
}
|
||
if fieldMapping != "" {
|
||
fieldMappingValue, err := parseAitableStringMap(fieldMapping, "field-mapping")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
importParams["fieldMapping"] = fieldMappingValue
|
||
}
|
||
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "aitable", "import_data", importParams,
|
||
))
|
||
}
|
||
return runAitableTool(cmd, runner, "import_data", importParams)
|
||
}
|
||
|
||
func parseAitableStringMap(raw, flagName string) (map[string]string, error) {
|
||
var parsed map[string]string
|
||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s must be a JSON object with string values", flagName))
|
||
}
|
||
if parsed == nil {
|
||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s must be a JSON object with string values", flagName))
|
||
}
|
||
return parsed, nil
|
||
}
|
||
|
||
// unwrapAitableResp 处理 MCP/runtime 常见响应包装层次。
|
||
func unwrapAitableResp(resp map[string]any) map[string]any {
|
||
if resp == nil {
|
||
return map[string]any{}
|
||
}
|
||
preserved := map[string]any{}
|
||
for depth := 0; depth < 8; depth++ {
|
||
preserveAitableWrapperFields(preserved, resp)
|
||
if content, ok := resp["content"].(map[string]any); ok && len(content) > 0 {
|
||
resp = content
|
||
continue
|
||
}
|
||
if data, ok := resp["data"].(map[string]any); ok && len(data) > 0 {
|
||
resp = data
|
||
continue
|
||
}
|
||
if result, ok := resp["result"].(map[string]any); ok && len(result) > 0 {
|
||
resp = result
|
||
continue
|
||
}
|
||
if raw, ok := resp["result"].(string); ok && strings.TrimSpace(raw) != "" {
|
||
var parsed map[string]any
|
||
if json.Unmarshal([]byte(raw), &parsed) == nil && len(parsed) > 0 {
|
||
resp = parsed
|
||
continue
|
||
}
|
||
}
|
||
break
|
||
}
|
||
out := copyAitableMap(resp)
|
||
for k, v := range preserved {
|
||
if k == "status" || k == "state" {
|
||
if s, ok := v.(string); ok && normalizeAsyncStatus(s, false) == asynctask.StatusFailed {
|
||
out["status"] = s
|
||
continue
|
||
}
|
||
}
|
||
if _, exists := out[k]; !exists {
|
||
out[k] = v
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func preserveAitableWrapperFields(dst, layer map[string]any) {
|
||
for k, v := range layer {
|
||
switch k {
|
||
case "content", "data", "result":
|
||
continue
|
||
}
|
||
if _, ok := v.(map[string]any); ok {
|
||
continue
|
||
}
|
||
if _, exists := dst[k]; !exists {
|
||
dst[k] = v
|
||
}
|
||
}
|
||
if s := firstAitableString(layer, "status", "state"); normalizeAsyncStatus(s, false) == asynctask.StatusFailed {
|
||
dst["status"] = s
|
||
}
|
||
}
|
||
|
||
func normalizeAitableDownloadURL(raw string) string {
|
||
url := strings.TrimSpace(raw)
|
||
if url == "" || strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
|
||
return url
|
||
}
|
||
return "https://" + url
|
||
}
|
||
|
||
func firstAitableString(values map[string]any, keys ...string) string {
|
||
for _, key := range keys {
|
||
if s, ok := values[key].(string); ok && strings.TrimSpace(s) != "" {
|
||
return strings.TrimSpace(s)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func copyAitableMap(values map[string]any) map[string]any {
|
||
out := make(map[string]any, len(values))
|
||
for k, v := range values {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parseAitableExportQueryResult 解析 export_data 查询返回。
|
||
func parseAitableExportQueryResult(resp map[string]any) asynctask.QueryResult {
|
||
data := unwrapAitableResp(resp)
|
||
statusRaw := firstAitableString(data, "status", "state")
|
||
url := normalizeAitableDownloadURL(firstAitableString(data, "downloadUrl", "downloadURL", "url"))
|
||
msg := firstAitableString(data, "message", "msg", "errorMessage")
|
||
// 兼容:部分上游用 SUCCEED / FAILURE 等变体
|
||
st := normalizeAsyncStatus(statusRaw, url != "")
|
||
if st == asynctask.StatusSuccess && url == "" {
|
||
st = asynctask.StatusProcessing
|
||
}
|
||
return asynctask.QueryResult{
|
||
Status: st,
|
||
DownloadURL: url,
|
||
Message: msg,
|
||
Raw: data,
|
||
}
|
||
}
|
||
|
||
// normalizeAsyncStatus 把各种 status 变体规范化到 asynctask.Status。
|
||
// hasResult=true 时即便 status 缺失也判定 SUCCESS(部分上游用"data 已就位"暗示完成)。
|
||
func normalizeAsyncStatus(raw string, hasResult bool) asynctask.Status {
|
||
s := strings.ToUpper(strings.TrimSpace(raw))
|
||
switch s {
|
||
case "SUCCESS", "SUCCEED", "SUCCEEDED", "DONE", "FINISHED", "COMPLETE", "COMPLETED":
|
||
return asynctask.StatusSuccess
|
||
case "FAILED", "FAILURE", "ERROR":
|
||
return asynctask.StatusFailed
|
||
case "PROCESSING", "RUNNING", "PENDING", "QUEUED", "IN_PROGRESS":
|
||
return asynctask.StatusProcessing
|
||
case "":
|
||
if hasResult {
|
||
return asynctask.StatusSuccess
|
||
}
|
||
return asynctask.StatusProcessing
|
||
default:
|
||
// 未知状态:保守处理为 processing 让上层继续等
|
||
return asynctask.StatusProcessing
|
||
}
|
||
}
|