Files
dingtalk-workspace-cli/internal/helpers/sheet_media.go

400 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
}