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.
322 lines
12 KiB
Go
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
|
|
}
|