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

207 lines
7.5 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"
"os"
"path/filepath"
"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/spf13/cobra"
)
// newChatFileGroup builds `dws chat file upload`. wukong implements it as a
// multi-step upload to the conversation file space: for --file it does
// init_conversation_file_upload (im) -> HTTP PUT -> commit_conversation_file_upload
// (im); for --url it calls upload_conversation_file_by_url (chat). The envelope
// cannot express the local pipeline, so it lives here. Wired into the chat
// handler (see chat.go).
func newChatFileGroup(runner executor.Runner) *cobra.Command {
file := &cobra.Command{
Use: "file",
Short: "会话文件上传",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
file.AddCommand(newChatFileUploadCommand(runner))
return file
}
func newChatFileUploadCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件或 URL 文件到会话文件空间",
Example: " dws chat file upload --group <openConversationId> --file ./report.pdf\n" +
" dws chat file upload --user <userId> --url https://example.com/a.pdf --file-name a.pdf",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
target, err := chatConversationTargetArgs(cmd)
if err != nil {
return err
}
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file", "file-path"))
fileURL := strings.TrimSpace(firstNonEmptyFlag(cmd, "url"))
if filePath == "" && fileURL == "" {
return apperrors.NewValidation("--file or --url is required")
}
if filePath != "" && fileURL != "" {
return apperrors.NewValidation("--file and --url are mutually exclusive")
}
fileName := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-name"))
md5v := strings.TrimSpace(firstNonEmptyFlag(cmd, "md5"))
uuid := strings.TrimSpace(firstNonEmptyFlag(cmd, "uuid"))
// URL path: server pulls the file (chat server).
if fileURL != "" {
if fileName == "" {
fileName = filepath.Base(fileURL)
}
params := cloneStringAnyMap(target)
params["fileUrl"] = fileURL
params["fileName"] = fileName
if md5v != "" {
params["md5"] = md5v
}
if uuid != "" {
params["uuid"] = uuid
}
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "chat", "upload_conversation_file_by_url", params)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, inv)
}
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
// Local path: init (im) -> HTTP PUT -> commit (im).
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation("cannot read file " + filePath + ": " + err.Error())
}
if fi.IsDir() {
return apperrors.NewValidation(filePath + " is a directory, not a file")
}
if fileName == "" {
fileName = filepath.Base(filePath)
}
fileSize := fi.Size()
if md5v == "" {
if md5v, err = fileMD5Hex(filePath); err != nil {
return err
}
}
if commandDryRun(cmd) {
preview := cloneStringAnyMap(target)
preview["fileName"] = fileName
preview["fileSize"] = fileSize
preview["md5"] = md5v
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", preview))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
initParams := cloneStringAnyMap(target)
initParams["fileName"] = fileName
initParams["fileSize"] = fileSize
initParams["md5"] = md5v
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", initParams))
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("incomplete upload credentials: resourceUrl=" + resourceURL + " uploadKey=" + uploadKey)
}
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
commitParams := cloneStringAnyMap(target)
commitParams["uploadKey"] = uploadKey
commitParams["fileName"] = fileName
commitParams["fileSize"] = fileSize
commitParams["md5"] = md5v
if uuid != "" {
commitParams["uuid"] = uuid
}
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "commit_conversation_file_upload", commitParams))
if err != nil {
return err
}
return writeCommandPayload(cmd, commitRes)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
cmd.Flags().String("conversation-id", "", "--group 的别名")
cmd.Flags().String("id", "", "--group 的别名")
cmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)")
cmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)")
cmd.Flags().String("file", "", "本地文件路径(与 --url 二选一)")
cmd.Flags().String("file-path", "", "--file 的别名")
cmd.Flags().String("url", "", "远程文件 URL(与 --file 二选一,服务端代传)")
cmd.Flags().String("file-name", "", "文件名(可选)")
cmd.Flags().String("md5", "", "文件 MD5(可选,本地不传自动计算)")
cmd.Flags().String("uuid", "", "幂等 UUID(可选)")
return cmd
}
func chatConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) {
group := strings.TrimSpace(firstNonEmptyFlag(cmd, "group", "conversation-id", "id"))
user := strings.TrimSpace(firstNonEmptyFlag(cmd, "user"))
openDingTalkID := strings.TrimSpace(firstNonEmptyFlag(cmd, "open-dingtalk-id"))
if group == "" && user == "" && openDingTalkID == "" {
return nil, apperrors.NewValidation("需指定会话目标:--group(群聊)或 --user / --open-dingtalk-id(单聊)之一")
}
m := map[string]any{}
if group != "" {
m["openConversationId"] = group
}
if user != "" {
m["userId"] = user
}
if openDingTalkID != "" {
m["openDingTalkId"] = openDingTalkID
}
return m, nil
}
func cloneStringAnyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in)+4)
for k, v := range in {
out[k] = v
}
return out
}