400 lines
13 KiB
Go
400 lines
13 KiB
Go
package helpers
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
func runSheetMediaUpload(cmd *cobra.Command, _ []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
filePath := mustGetFlag(cmd, "file")
|
||
if filePath == "" {
|
||
return fmt.Errorf("flag --file is required")
|
||
}
|
||
|
||
fileInfo, err := os.Stat(filePath)
|
||
if err != nil {
|
||
return fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||
}
|
||
if fileInfo.IsDir() {
|
||
return fmt.Errorf("%s is a directory, not a file", filePath)
|
||
}
|
||
|
||
fileName, _ := cmd.Flags().GetString("name")
|
||
if fileName == "" {
|
||
fileName = filepath.Base(filePath)
|
||
} else if filepath.Ext(fileName) == "" {
|
||
if ext := filepath.Ext(filePath); ext != "" {
|
||
fileName += ext
|
||
}
|
||
}
|
||
|
||
mimeType, _ := cmd.Flags().GetString("mime-type")
|
||
if mimeType == "" {
|
||
mimeType = inferMimeType(fileName)
|
||
}
|
||
|
||
fileSize := fileInfo.Size()
|
||
|
||
if deps.Caller.DryRun() {
|
||
deps.Out.PrintKeyValue("操作", "上传附件到表格")
|
||
deps.Out.PrintKeyValue("表格", nodeID)
|
||
deps.Out.PrintKeyValue("文件", filePath)
|
||
deps.Out.PrintKeyValue("名称", fileName)
|
||
deps.Out.PrintKeyValue("类型", mimeType)
|
||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
|
||
return nil
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
// json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout),
|
||
// 使得 agent 无法按 JSON 解析。故 json 模式抑制进度、末尾统一输出结果 JSON。
|
||
jsonMode := deps.Caller.Format() == "json"
|
||
|
||
if !jsonMode {
|
||
deps.Out.PrintInfo(fmt.Sprintf("[1/2] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||
}
|
||
|
||
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
|
||
"nodeId": nodeID,
|
||
"fileName": fileName,
|
||
"fileSize": float64(fileSize),
|
||
"mimeType": mimeType,
|
||
})
|
||
if err != nil {
|
||
return WrapError(err)
|
||
}
|
||
|
||
var credText string
|
||
for _, c := range result.Content {
|
||
if c.Type == "text" && c.Text != "" {
|
||
credText = c.Text
|
||
break
|
||
}
|
||
}
|
||
|
||
uploadURL, resourceID, _, err := parseAttachmentUploadInfo(credText)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var resourceURL string
|
||
var credData map[string]any
|
||
if json.Unmarshal([]byte(credText), &credData) == nil {
|
||
if result, ok := credData["result"].(map[string]any); ok {
|
||
credData = result
|
||
}
|
||
resourceURL, _ = credData["resourceUrl"].(string)
|
||
}
|
||
|
||
if !jsonMode {
|
||
deps.Out.PrintKeyValue("resourceId", resourceID)
|
||
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
|
||
deps.Out.PrintInfo("[2/2] 上传文件到 OSS...")
|
||
}
|
||
|
||
ossHeaders := map[string]string{
|
||
"Content-Type": mimeType,
|
||
}
|
||
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
|
||
return err
|
||
}
|
||
|
||
if jsonMode {
|
||
return deps.Out.PrintJSON(map[string]any{
|
||
"success": true,
|
||
"resourceId": resourceID,
|
||
"resourceUrl": resourceURL,
|
||
"fileName": fileName,
|
||
"fileSize": fileSize,
|
||
})
|
||
}
|
||
deps.Out.PrintInfo(fmt.Sprintf("附件已上传: %s (resourceId=%s)", fileName, resourceID))
|
||
return nil
|
||
}
|
||
|
||
func runSheetWriteImage(cmd *cobra.Command, _ []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
sheetID := mustGetFlag(cmd, "sheet-id")
|
||
if sheetID == "" {
|
||
return fmt.Errorf("flag --sheet-id is required")
|
||
}
|
||
|
||
rangeAddress := mustGetFlag(cmd, "range")
|
||
if rangeAddress == "" {
|
||
return fmt.Errorf("flag --range is required")
|
||
}
|
||
|
||
filePath := mustGetFlag(cmd, "file")
|
||
if filePath == "" {
|
||
return fmt.Errorf("flag --file is required")
|
||
}
|
||
|
||
fileInfo, err := os.Stat(filePath)
|
||
if err != nil {
|
||
return fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||
}
|
||
if fileInfo.IsDir() {
|
||
return fmt.Errorf("%s is a directory, not a file", filePath)
|
||
}
|
||
|
||
fileName, _ := cmd.Flags().GetString("name")
|
||
if fileName == "" {
|
||
fileName = filepath.Base(filePath)
|
||
} else if filepath.Ext(fileName) == "" {
|
||
if ext := filepath.Ext(filePath); ext != "" {
|
||
fileName += ext
|
||
}
|
||
}
|
||
|
||
mimeType, _ := cmd.Flags().GetString("mime-type")
|
||
if mimeType == "" {
|
||
mimeType = inferMimeType(fileName)
|
||
}
|
||
|
||
fileSize := fileInfo.Size()
|
||
|
||
if deps.Caller.DryRun() {
|
||
deps.Out.PrintKeyValue("操作", "上传图片并写入表格")
|
||
deps.Out.PrintKeyValue("表格", nodeID)
|
||
deps.Out.PrintKeyValue("工作表", sheetID)
|
||
deps.Out.PrintKeyValue("单元格", rangeAddress)
|
||
deps.Out.PrintKeyValue("文件", filePath)
|
||
deps.Out.PrintKeyValue("名称", fileName)
|
||
deps.Out.PrintKeyValue("类型", mimeType)
|
||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
|
||
return nil
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
// json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout),
|
||
// 使 write_image 的 JSON 响应无法被单独解析。故 json 模式抑制进度。
|
||
jsonMode := deps.Caller.Format() == "json"
|
||
|
||
if !jsonMode {
|
||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||
}
|
||
|
||
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
|
||
"nodeId": nodeID,
|
||
"fileName": fileName,
|
||
"fileSize": float64(fileSize),
|
||
"mimeType": mimeType,
|
||
})
|
||
if err != nil {
|
||
return WrapError(err)
|
||
}
|
||
|
||
var credText string
|
||
for _, c := range result.Content {
|
||
if c.Type == "text" && c.Text != "" {
|
||
credText = c.Text
|
||
break
|
||
}
|
||
}
|
||
|
||
uploadURL, resourceID, _, err := parseAttachmentUploadInfo(credText)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var resourceURL string
|
||
var credData map[string]any
|
||
if json.Unmarshal([]byte(credText), &credData) == nil {
|
||
if r, ok := credData["result"].(map[string]any); ok {
|
||
credData = r
|
||
}
|
||
resourceURL, _ = credData["resourceUrl"].(string)
|
||
}
|
||
|
||
if !jsonMode {
|
||
deps.Out.PrintKeyValue("resourceId", resourceID)
|
||
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
|
||
deps.Out.PrintInfo("[2/3] 上传图片到 OSS...")
|
||
}
|
||
|
||
ossHeaders := map[string]string{
|
||
"Content-Type": mimeType,
|
||
}
|
||
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
|
||
return err
|
||
}
|
||
|
||
if !jsonMode {
|
||
deps.Out.PrintInfo("[3/3] 写入图片到表格单元格...")
|
||
}
|
||
|
||
writeArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"sheetId": sheetID,
|
||
"rangeAddress": rangeAddress,
|
||
"resourceId": resourceID,
|
||
"resourceUrl": resourceURL,
|
||
}
|
||
if w, _ := cmd.Flags().GetInt("width"); cmd.Flags().Changed("width") {
|
||
writeArgs["width"] = w
|
||
}
|
||
if h, _ := cmd.Flags().GetInt("height"); cmd.Flags().Changed("height") {
|
||
writeArgs["height"] = h
|
||
}
|
||
|
||
if jsonMode {
|
||
// json 模式:write_image 的 JSON 响应就是唯一 stdout 输出。
|
||
return callMCPTool("write_image", writeArgs)
|
||
}
|
||
if err := callMCPTool("write_image", writeArgs); err != nil {
|
||
return err
|
||
}
|
||
deps.Out.PrintInfo(fmt.Sprintf("图片已写入表格: %s → %s (resourceId=%s)", fileName, rangeAddress, resourceID))
|
||
return nil
|
||
}
|
||
|
||
// ── media-upload + write-image 命令定义 ──────────────────────────────────────
|
||
|
||
func newMediaCmds() []*cobra.Command {
|
||
mediaUploadCmd := &cobra.Command{
|
||
Use: "media-upload",
|
||
Short: "上传附件到表格",
|
||
Long: `将本地文件作为附件上传到钉钉表格(两步自动完成)。
|
||
|
||
流程:
|
||
1. 获取附件上传凭证 (get_doc_attachment_upload_info)
|
||
2. HTTP PUT 上传文件到 OSS
|
||
|
||
上传成功后返回 resourceId,可用于后续引用。
|
||
--mime-type 可选,不指定时根据文件扩展名自动推断。`,
|
||
Example: ` # 上传 PDF 附件
|
||
dws sheet media-upload --node SHEET_DOC_ID --file ./report.pdf
|
||
|
||
# 指定名称和 MIME 类型
|
||
dws sheet media-upload --node SHEET_DOC_ID --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream`,
|
||
RunE: runSheetMediaUpload,
|
||
}
|
||
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "sheet",
|
||
Name: "media_upload",
|
||
CanonicalPath: "sheet.media_upload",
|
||
CLIPath: "sheet media-upload",
|
||
PrimaryCLIPath: "sheet media-upload",
|
||
},
|
||
Description: "上传附件到表格并返回 resourceUrl(浮动图片前置步骤)。",
|
||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "上传附件到表格并返回 resourceUrl(浮动图片前置步骤)。",
|
||
UseWhen: []string{"需要把本地文件上传为表格资源,或为 create-float-image 准备 src 时"},
|
||
AvoidWhen: []string{"要把图片写入单元格内容用 write-image;AI 表格附件用 aitable attachment upload"},
|
||
Examples: []string{"dws sheet media-upload --node <NODE_ID> --file ./report.pdf"},
|
||
},
|
||
},
|
||
})
|
||
mediaUploadCmd.Flags().String("node", "", "目标表格文档的标识,支持传入 URL 或 ID (必填)")
|
||
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||
mediaUploadCmd.Flags().String("name", "", "附件显示名称 (默认使用文件名)")
|
||
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
|
||
mediaUploadCmd.Flags().String("url", "", "--node 的别名")
|
||
mediaUploadCmd.Flags().String("id", "", "--node 的别名")
|
||
mediaUploadCmd.Flags().String("node-id", "", "--node 的别名")
|
||
mediaUploadCmd.Flags().String("doc-id", "", "--node 的别名")
|
||
_ = mediaUploadCmd.Flags().MarkHidden("url")
|
||
_ = mediaUploadCmd.Flags().MarkHidden("id")
|
||
_ = mediaUploadCmd.Flags().MarkHidden("node-id")
|
||
_ = mediaUploadCmd.Flags().MarkHidden("doc-id")
|
||
|
||
writeImageCmd := &cobra.Command{
|
||
Use: "write-image",
|
||
Short: "上传图片并写入表格单元格",
|
||
Long: `将本地图片上传并写入到钉钉表格的指定单元格(三步自动完成)。
|
||
|
||
流程:
|
||
1. 获取附件上传凭证 (get_doc_attachment_upload_info)
|
||
2. HTTP PUT 上传图片到 OSS
|
||
3. 调用 write_image 将图片写入指定单元格
|
||
|
||
--mime-type 可选,不指定时根据文件扩展名自动推断。
|
||
--width / --height 可选,设置图片在单元格中的显示尺寸。`,
|
||
Example: ` # 写入图片到 A1:A1 单元格
|
||
dws sheet write-image --node SHEET_DOC_ID --sheet-id SHEET_ID --range A1:A1 --file ./chart.png
|
||
|
||
# 指定显示尺寸
|
||
dws sheet write-image --node SHEET_DOC_ID --sheet-id SHEET_ID --range B2:B2 --file ./logo.png --width 200 --height 100`,
|
||
RunE: runSheetWriteImage,
|
||
}
|
||
DeclareLeafMetadata(writeImageCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "sheet",
|
||
Name: "write_image",
|
||
CanonicalPath: "sheet.write_image",
|
||
CLIPath: "sheet write-image",
|
||
PrimaryCLIPath: "sheet write-image",
|
||
},
|
||
Description: "上传图片并写入指定单元格(占单元格内容)。",
|
||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "write_image"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "上传图片并写入指定单元格(占单元格内容)。",
|
||
UseWhen: []string{"需要把图片嵌进某个单元格时"},
|
||
AvoidWhen: []string{"悬浮在单元格上的浮动图用 create-float-image;禁止用 range update 写图片"},
|
||
Examples: []string{"dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range A1:A1 --file ./chart.png"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "range", Property: "rangeAddress"},
|
||
},
|
||
},
|
||
})
|
||
writeImageCmd.Flags().String("node", "", "目标表格文档的标识,支持传入 URL 或 ID (必填)")
|
||
writeImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||
writeImageCmd.Flags().String("range", "", "目标单元格区域地址,如 A1:B3 (必填)")
|
||
writeImageCmd.Flags().String("file", "", "本地图片文件路径 (必填)")
|
||
writeImageCmd.Flags().String("name", "", "图片显示名称 (默认使用文件名)")
|
||
writeImageCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
|
||
writeImageCmd.Flags().Int("width", 0, "图片显示宽度 (可选)")
|
||
writeImageCmd.Flags().Int("height", 0, "图片显示高度 (可选)")
|
||
writeImageCmd.Flags().String("url", "", "--node 的别名")
|
||
writeImageCmd.Flags().String("id", "", "--node 的别名")
|
||
writeImageCmd.Flags().String("node-id", "", "--node 的别名")
|
||
writeImageCmd.Flags().String("doc-id", "", "--node 的别名")
|
||
_ = writeImageCmd.Flags().MarkHidden("url")
|
||
_ = writeImageCmd.Flags().MarkHidden("id")
|
||
_ = writeImageCmd.Flags().MarkHidden("node-id")
|
||
_ = writeImageCmd.Flags().MarkHidden("doc-id")
|
||
|
||
return []*cobra.Command{mediaUploadCmd, writeImageCmd}
|
||
}
|