Files
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2026-06-26 21:49:38 +08:00

329 lines
9.9 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 (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"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/spf13/cobra"
)
// newTodoAddAttachmentCommand builds the hardcoded `todo task add-attachment`
// leaf. The MCP backend exposes attachment upload as a multi-step orchestration
// that the envelope/pipeline layer cannot express (no upload step, local file
// IO): init upload credentials -> HTTP PUT the local file -> commit -> add.
// wukong implements it in code; this is the open-edition equivalent. It is
// wired into the existing todo handler's task group (see todo.go).
func newTodoAddAttachmentCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add-attachment",
Short: i18n.T("上传待办附件"),
Long: i18n.T("上传本地文件作为待办附件(init → 上传 → commit → add 四步)。会真实上传文件,请确认待办存在。"),
Example: " dws todo task add-attachment --task-id <taskId> --file-path <filePath>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
taskID := strings.TrimSpace(firstNonEmptyFlag(cmd, "task-id", "id"))
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-path", "file"))
if taskID == "" {
return apperrors.NewValidation("missing required flag(s): --task-id")
}
if filePath == "" {
return apperrors.NewValidation("missing required flag(s): --file-path")
}
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("cannot read file %s: %v", filePath, err))
}
if fi.IsDir() {
return apperrors.NewValidation(fmt.Sprintf("%s is a directory, not a file", filePath))
}
fileName := filepath.Base(filePath)
fileType := strings.TrimPrefix(filepath.Ext(fileName), ".")
fileSize := fi.Size()
md5Hex, err := fileMD5Hex(filePath)
if err != nil {
return err
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "add_todo_attachment", map[string]any{
"todoAttachmentAddRequest": map[string]any{
"taskId": taskID,
"fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
},
}))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
// 1) init upload credentials
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "init_todo_file_upload", map[string]any{
"todoAttachmentInitUploadInfoRequest": map[string]any{
"fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
},
}))
if err != nil {
return err
}
resourceURL := findStringDeep(initRes.Response, "resourceUrl", "resourceURL", "url")
if resourceURL == "" {
resourceURL = findFirstInStringArrayDeep(initRes.Response, "resourceUrls", "resourceURLs")
}
uploadKey := findStringDeep(initRes.Response, "uploadKey", "key")
if resourceURL == "" || uploadKey == "" {
return apperrors.NewAPI(fmt.Sprintf("incomplete upload credentials: resourceUrl=%q uploadKey=%q", resourceURL, uploadKey))
}
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
// 2) PUT the local file to the resource URL
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
// 3) commit upload
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "commit_todo_file_upload", map[string]any{
"todoAttachmentCommitUploadInfoRequest": map[string]any{
"uploadKey": uploadKey, "fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
},
}))
if err != nil {
return err
}
dentryID := findInt64Deep(commitRes.Response, "dentryId", "dentryID")
spaceID := findInt64Deep(commitRes.Response, "spaceId", "spaceID")
if dentryID == 0 || spaceID == 0 {
return apperrors.NewAPI("uploaded file response missing dentryId or spaceId")
}
// 4) add attachment to the todo
addRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "add_todo_attachment", map[string]any{
"todoAttachmentAddRequest": map[string]any{
"taskId": taskID,
"attachmentList": []any{map[string]any{
"fileId": strconv.FormatInt(dentryID, 10),
"fileName": fileName,
"fileSize": fileSize,
"spaceId": strconv.FormatInt(spaceID, 10),
"fileType": fileType,
}},
},
}))
if err != nil {
return err
}
return writeCommandPayload(cmd, addRes)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)"))
cmd.Flags().String("id", "", i18n.T("--task-id 的别名"))
cmd.Flags().String("file-path", "", i18n.T("本地文件路径 (必填)"))
cmd.Flags().String("file", "", i18n.T("--file-path 的别名"))
return cmd
}
func fileMD5Hex(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("cannot open file %s: %v", path, err))
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, f); err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("md5 of %s: %v", path, err))
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func httpPutLocalFile(ctx context.Context, url string, headers map[string]string, path string, size int64) error {
f, err := os.Open(path)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("cannot open file %s: %v", path, err))
}
defer f.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, f)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("build PUT request: %v", err))
}
req.ContentLength = size
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return apperrors.NewAPI(fmt.Sprintf("upload PUT failed: %v", err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return apperrors.NewAPI(fmt.Sprintf("upload PUT returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
}
return nil
}
// findStringDeep recursively searches a decoded JSON value for the first
// non-empty string value under any of the given keys.
func findStringDeep(v any, keys ...string) string {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if s, ok := scalarString(t[k]); ok && s != "" {
return s
}
}
for _, child := range t {
if s := findStringDeep(child, keys...); s != "" {
return s
}
}
case []any:
for _, child := range t {
if s := findStringDeep(child, keys...); s != "" {
return s
}
}
}
return ""
}
// findFirstInStringArrayDeep recursively finds the first non-empty string in an
// array stored under any of the given keys (e.g. "resourceUrls").
func findFirstInStringArrayDeep(v any, keys ...string) string {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if arr, ok := t[k].([]any); ok {
for _, e := range arr {
if s, ok := scalarString(e); ok && s != "" {
return s
}
}
}
}
for _, child := range t {
if s := findFirstInStringArrayDeep(child, keys...); s != "" {
return s
}
}
case []any:
for _, child := range t {
if s := findFirstInStringArrayDeep(child, keys...); s != "" {
return s
}
}
}
return ""
}
func findInt64Deep(v any, keys ...string) int64 {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if n, ok := scalarInt64(t[k]); ok && n != 0 {
return n
}
}
for _, child := range t {
if n := findInt64Deep(child, keys...); n != 0 {
return n
}
}
case []any:
for _, child := range t {
if n := findInt64Deep(child, keys...); n != 0 {
return n
}
}
}
return 0
}
func findHeadersDeep(v any, keys ...string) map[string]string {
out := map[string]string{}
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if h, ok := t[k].(map[string]any); ok {
for name, val := range h {
if s, ok := scalarString(val); ok && s != "" {
out[name] = s
}
}
}
}
if len(out) == 0 {
for _, child := range t {
if h := findHeadersDeep(child, keys...); len(h) > 0 {
return h
}
}
}
}
return out
}
func scalarString(v any) (string, bool) {
switch s := v.(type) {
case string:
return s, true
case json.Number:
return s.String(), true
case float64:
return strconv.FormatFloat(s, 'f', -1, 64), true
}
return "", false
}
func scalarInt64(v any) (int64, bool) {
switch n := v.(type) {
case json.Number:
if i, err := n.Int64(); err == nil {
return i, true
}
if f, err := n.Float64(); err == nil {
return int64(f), true
}
case float64:
return int64(n), true
case int64:
return n, true
case string:
if i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64); err == nil {
return i, true
}
}
return 0, false
}