Files
玉澜 9567cfd3d8 fix(review): align wukong port with upstream behavior and PR #621 review findings
Must-fix: drive permission apply now gates on confirmDangerousAction and
declares confirmation=user_required, matching its help-text promise.

Wukong parity restored: formula-verify --exit-on-error (payload-parsing
exit path) and --targets conflict error, sheet info --include, chat
location/profile message types, search-advanced wukong flag aliases,
and a dedicated drive download-version leaf replacing the removed
polymorphic download --version.

Consistency fixes: transfer-owner --node/--workspace XOR and JSON-aware
dry-run after --yes validation; drive list --versions rejects
--depth/--pattern instead of misleading depth errors; depth BFS resumes
rate-limited folders from the failed page cursor to avoid duplicates;
doc style cover upload honors cmd.Context() and a 20 MiB size cap; chat
user-settings set validates per-item openConversationId and is
risk=medium.

Hardened the skill static audit to scan fenced code blocks and reject
unknown subcommands on group commands, fixing the stale aitable/drive
doc examples it exposed. Added CHANGELOG entry and coverage tests for
all changed statements plus previously untested ported commands.
2026-07-29 15:34:59 +08:00

322 lines
12 KiB
Go

package helpers
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// doc_style.go — `dws doc style`(文档封面/背景样式配置)。
// 写入收口到 MCP 工具 update_document_style;读取用 get_document_style。
const docStyleToolName = "update_document_style"
const docStyleGetToolName = "get_document_style"
// newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。
func newDocStyleCommand() *cobra.Command {
styleCmd := &cobra.Command{
Use: "style",
Short: "文档样式配置 (封面/背景)",
Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。
命令结构:
dws doc style cover set 设置文档封面
dws doc style cover clear 移除文档封面
dws doc style background set 设置文档背景纯色
dws doc style background clear 清除文档背景
dws doc style get 读取文档封面/背景 (只读)
封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`,
RunE: groupRunE,
}
coverCmd := &cobra.Command{
Use: "cover",
Short: "文档封面设置/移除",
RunE: groupRunE,
}
coverSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档封面",
Long: `为钉钉文档设置顶部封面图,可指定竖直显示位置。图片来源二选一:--image 外链或 --file 本地文件。`,
Example: ` dws doc style cover set --node DOC_ID --image https://img.example.com/cover.png
dws doc style cover set --node DOC_ID --file ./cover.png --position 0.3`,
RunE: runDocStyleCoverSet,
}
coverClearCmd := &cobra.Command{
Use: "clear",
Short: "移除文档封面",
Example: ` dws doc style cover clear --node DOC_ID`,
RunE: runDocStyleCoverClear,
}
backgroundCmd := &cobra.Command{
Use: "background",
Short: "文档背景设置/清除",
RunE: groupRunE,
}
backgroundSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档背景纯色",
Long: `为钉钉文档设置背景纯色(--color)。背景仅支持纯色,不支持背景图片上传(对齐前端能力)。`,
Example: ` dws doc style background set --node DOC_ID --color "#E8F2FE"`,
RunE: runDocStyleBackgroundSet,
}
backgroundClearCmd := &cobra.Command{
Use: "clear",
Short: "清除文档背景",
Example: ` dws doc style background clear --node DOC_ID`,
RunE: runDocStyleBackgroundClear,
}
getCmd := &cobra.Command{
Use: "get",
Short: "读取文档封面/背景 (只读)",
Long: `读取钉钉文档当前的封面与背景配置(只读,单接口收口 get_document_style)。`,
Example: ` dws doc style get --node DOC_ID`,
RunE: runDocStyleGet,
}
// cover set flags
coverSetCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
coverSetCmd.Flags().String("image", "", "封面图片 URL (外链会自动转存为内部地址)")
coverSetCmd.Flags().String("file", "", "本地图片文件路径 (与 --image 互斥)")
coverSetCmd.Flags().Float64("position", 0.5, "封面竖直位置 [0,1],默认 0.5")
coverSetCmd.MarkFlagsMutuallyExclusive("image", "file")
// cover clear flags
coverClearCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
// background set flags(仅纯色,对齐前端;不支持背景图片上传)
backgroundSetCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
backgroundSetCmd.Flags().String("color", "", "背景纯色,如 #E8F2FE (必填)")
// background clear flags
backgroundClearCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
// get flags
getCmd.Flags().String("node", "", "目标文档标识,支持 URL 或 ID (必填)")
// --node 隐藏别名 (--url/--id/--node-id/--doc-id/--file-id)
for _, c := range []*cobra.Command{coverSetCmd, coverClearCmd, backgroundSetCmd, backgroundClearCmd, getCmd} {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
c.Flags().String("node-id", "", "--node 的别名")
c.Flags().String("doc-id", "", "--node 的别名")
c.Flags().String("file-id", "", "--node 的别名 (跨产品兼容 drive)")
_ = c.Flags().MarkHidden("url")
_ = c.Flags().MarkHidden("id")
_ = c.Flags().MarkHidden("node-id")
_ = c.Flags().MarkHidden("doc-id")
_ = c.Flags().MarkHidden("file-id")
}
coverCmd.AddCommand(coverSetCmd, coverClearCmd)
backgroundCmd.AddCommand(backgroundSetCmd, backgroundClearCmd)
styleCmd.AddCommand(coverCmd, backgroundCmd, getCmd)
// 递归注册跨产品别名,让 cover set 的 --file 获得统一别名 --file-path。
registerCrossProductAliasesRecursive(styleCmd)
return styleCmd
}
// registerCrossProductAliasesRecursive 递归为 cmd 及其所有子命令注册跨产品别名。
// 可正确处理多层分组(如 style 的 cover/background 组下还有 set/clear 叶子)。
func registerCrossProductAliasesRecursive(cmd *cobra.Command) {
RegisterCrossProductAliases(cmd)
for _, sub := range cmd.Commands() {
registerCrossProductAliasesRecursive(sub)
}
}
// docStyleNodeID 解析目标文档标识,支持 --node 及隐藏别名。
func docStyleNodeID(cmd *cobra.Command) (string, error) {
return mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
}
func runDocStyleCoverSet(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
// 封面图片来源二选一:--image 外链(服务端转存公开)或 --file 本地上传(拿 resourceId 转存公开)。
image, _ := cmd.Flags().GetString("image")
filePath := flagOrFallback(cmd, "file", "file-path")
// --image 与 --file/--file-path 互斥。cobra 的 MarkFlagsMutuallyExclusive 只认主名
// image/file,拦不住别名 --file-path,故在此显式兜底。
if image != "" && filePath != "" {
return fmt.Errorf("--image and --file/--file-path are mutually exclusive; specify only one")
}
if image == "" && filePath == "" {
return fmt.Errorf("flag --image or --file is required for `cover set`")
}
cover := map[string]any{"action": "set"}
// position 客户端校验:帮助文档承诺 [0,1],越界直接报错,避免触发上传/请求。
if cmd.Flags().Changed("position") {
pos, _ := cmd.Flags().GetFloat64("position")
if pos < 0 || pos > 1 {
return fmt.Errorf("--position must be within [0,1], got %v", pos)
}
cover["position"] = pos
}
if image != "" {
cover["imageUrl"] = image
} else {
// --file:先做本地校验(存在 + 必须为图片);dry-run 时在上传前短路,
// 绝不读取/上传本地文件。
mimeType, fileSize, err := validateCoverImageFile(filePath)
if err != nil {
return err
}
if deps.Caller.DryRun() {
cover["resourceId"] = fmt.Sprintf("(pending upload from --file %s)", filePath)
} else {
// 使用命令上下文,让 Ctrl-C 能够取消上传凭证获取与 OSS 上传两次网络请求。
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
resourceID, err := uploadDocStyleImage(ctx, nodeID, filePath, filepath.Base(filePath), mimeType, fileSize)
if err != nil {
return err
}
cover["resourceId"] = resourceID
}
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"cover": cover,
})
}
func runDocStyleCoverClear(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"cover": map[string]any{"action": "clear"},
})
}
func runDocStyleBackgroundSet(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
// 背景仅支持纯色(对齐前端,不支持背景图片上传)。
color, _ := cmd.Flags().GetString("color")
if color == "" {
return fmt.Errorf("flag --color is required for `background set`")
}
// 帮助文档承诺 #RRGGBB 十六进制纯色,非法值直接报错,不下发请求。
if !isHexColor(color) {
return fmt.Errorf("--color must be a hex color like #E8F2FE, got %q", color)
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"background": map[string]any{
"action": "set",
"backgroundColor": color,
},
})
}
func runDocStyleBackgroundClear(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("doc", docStyleToolName, map[string]any{
"nodeId": nodeID,
"background": map[string]any{"action": "clear"},
})
}
// runDocStyleGet 读取文档当前封面/背景(只读)。
func runDocStyleGet(cmd *cobra.Command, _ []string) error {
nodeID, err := docStyleNodeID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("doc", docStyleGetToolName, map[string]any{
"nodeId": nodeID,
})
}
// docStyleCoverMaxBytes 是 cover set --file 本地图片的大小上限(20 MiB)。
// 悟空参考实现未设上限,此处补充客户端 fail-fast,避免超大文件被先行上传。
const docStyleCoverMaxBytes = 20 << 20
// validateCoverImageFile 对 cover set --file 做本地校验(不上传/不读取文件内容):
// 文件须存在、非目录、不超过大小上限,且按扩展名推断的 MIME 必须为 image/*。
// 返回 mimeType 与文件大小,供 dry-run 预览与真实上传前 fail-fast,避免非法文件被先行上传。
func validateCoverImageFile(filePath string) (mimeType string, fileSize int64, err error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return "", 0, fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return "", 0, fmt.Errorf("%s is a directory, not a file", filePath)
}
if fileInfo.Size() > docStyleCoverMaxBytes {
return "", 0, fmt.Errorf("封面图片文件过大:上限 %d 字节 (20 MiB),实际 %d 字节 (%s)", int64(docStyleCoverMaxBytes), fileInfo.Size(), filepath.Base(filePath))
}
mimeType = inferMimeType(filepath.Base(filePath))
if !strings.HasPrefix(mimeType, "image/") {
return "", 0, fmt.Errorf("cover --file must be an image (png/jpg/jpeg/gif/svg/webp), got %s", filepath.Base(filePath))
}
return mimeType, fileInfo.Size(), nil
}
// isHexColor 校验 #RRGGBB 形式的十六进制颜色(大小写均可)。
func isHexColor(s string) bool {
if len(s) != 7 || s[0] != '#' {
return false
}
for _, c := range s[1:] {
switch {
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
default:
return false
}
}
return true
}
// uploadDocStyleImage 复用附件上传子流程(get_doc_attachment_upload_info + httpPutFile),
// 上传本地文件为鉴权资源,返回其 resourceId(供封面在服务端转存为公开读地址)。
// 入参 mimeType/fileSize 由调用方经 validateCoverImageFile 预校验得到;进度写 stderr,
// 保证 --format json 时 stdout 只含合法 JSON。本函数一定发起网络请求,调用方须自行做 dry-run 短路。
func uploadDocStyleImage(ctx context.Context, nodeID, filePath, fileName, mimeType string, fileSize int64) (string, error) {
deps.Out.PrintProgress(fmt.Sprintf("[1/2] 获取图片上传凭证 (%s, %d bytes)...", fileName, fileSize))
credText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileSize),
"mimeType": mimeType,
})
if err != nil {
return "", err
}
uploadURL, resourceID, _, err := parseAttachmentUploadInfo(credText)
if err != nil {
return "", err
}
deps.Out.PrintProgress("[2/2] 上传图片到 OSS...")
ossHeaders := map[string]string{"Content-Type": mimeType}
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
return "", err
}
return resourceID, nil
}