Files
Ari 109ad13844 fix: honor global --jq/--fields on product commands; round-2 QA fixes (#575)
* fix: honor global --jq/--fields on product commands; round-2 QA fixes

Make the global --jq / --fields output filters actually work for the
product (MCP) commands. The helper Formatter used by every product
command ignored them, so they were silent no-ops there (they already
worked for `dws api`). Expose Fields()/JQ() on the ToolCaller interface
and apply the existing output.WriteFiltered path in the helper
Formatter's PrintJSON. The handful of bespoke utility commands
(auth/config/profile/...) still encode directly and are documented as
such.

Additional CLI fixes surfaced by the second real-machine QA pass:
- sheet write-image: emit clean JSON under --format json (suppress the
  progress lines that leaked onto stdout, same as media-upload/export)
- sheet range batch-set-style: under --format json, collect per-item
  results into a single JSON object instead of printing N separate ones
- chat download-media: create the output directory when missing and
  strip URL-encoded path separators from the inferred filename so the
  file actually lands instead of failing on a missing subdirectory
- pat chmod, aitable, sheet, chat, attendance: correct --help text
  (real scope names, non-existent subcommands, flag requiredness,
  alxs -> axls typo)

Helper scripts (mono and multi):
- minutes_extract_todos: parse dingtalkTodoList/actions (there is no
  todos key), so todos are no longer silently dropped
- sync the multi copies of chat_export_messages / chat_history_with_user
  (were crashing with AttributeError), minutes_list_parse /
  minutes_recent_summary, and calendar_free_slot_finder to the fixed
  mono versions

Skill docs (mono and multi): correct return-structure keys, flag names,
deprecated command routing (doc download -> drive download), enum values
and server-side limitations across products; update the global
reference to note --jq/--fields now apply to product commands.

* fix(skill): make skill setup --dry-run a no-op preview; doc/help fixups

skill setup ignored the global --dry-run flag and always wrote the skill
files (overwriting an existing install). Short-circuit into a preview
that lists the source, target dirs and selected sub-skills without
touching the filesystem.

Also correct a few doc/help mismatches found in the round-3 health check:
- attendance vacation balance/records quick-reference examples were
  missing the required --leave-code flag
- mail mailbox list --help described the returned field as "mailboxes"
  but the real field is "emailAccounts"

* docs: clarify --fields projects top-level/list keys, use --jq for nested

* docs: drop QA voice ("真机") and don't state env-specific quirks as absolute rules

The QA-driven doc/comment edits leaked test-process narration ("真机实测")
and this environment/account's quirks stated as universal rules into the
skill files, which are general-purpose instructions for any org/account.
Strip the "真机" narration everywhere; reword environment-specific findings
(PUBLIC sharing disabled by org policy, transient 1002, sender-open-dingtalk-id
behaviour) from absolute bans into conditional hints; keep genuinely
universal command behaviour, just without the QA voice.
2026-07-08 14:06:57 +08:00

339 lines
10 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/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,
}
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,
}
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}
}