// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package helpers import ( "context" "encoding/json" "fmt" "strconv" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/spf13/cobra" ) const ( fileCommentServer = "doc-comment" listFileCommentsTool = "list_file_comments" createFileCommentTool = "create_file_comment" fileCommentMaxPageSize = 200 fileCommentMaxAutoPages = 10 fileCommentMaxContentLength = 2099 ) type fileCommentPage struct { nodeID string total any hasMore bool nextCursor string comments []map[string]any } func fileCommentNodeFlag() LeafFlag { return LeafFlag{Name: "node", Usage: "文件 ID (dentryUuid)、数字 dentry ID 或钉盘文件 URL", Required: true, Aliases: []string{"url", "id", "node-id", "file-id"}, Bind: "fileId", Trim: true} } func fileCommentSpaceIDFlag() LeafFlag { return LeafFlag{Name: "space-id", Usage: "钉盘空间 ID;仅数字 dentry ID 必填", Bind: "spaceId", OmitEmpty: true, Trim: true, RequiredWhen: "--node is a numeric dentry ID"} } // newDriveFileCommentCmd follows the existing resource-first comment surface: // doc comment, sheet comment, and drive comment. The public command belongs to // Drive, while the implementation routes to the shared doc-comment MCP server. func newDriveFileCommentCmd() *cobra.Command { commentCmd := &cobra.Command{ Use: "comment", Short: "普通文件评论管理", Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。", RunE: groupRunE, } listCmd := NewLeafCommand(LeafSpec{ Use: "list", Short: "查询普通文件评论列表", Long: `查询钉盘普通预览文件的评论。支持 dentryUuid、钉盘文件 URL,以及配合 --space-id 使用的数字 dentry ID。支持的文件类型由服务端判定。 默认返回一页;--all 固定按每页 200 条自动翻页,最多 10 页。--scope 在 CLI 侧按服务端返回的统一 anchor 过滤;total 始终表示文件的全部有效评论数,count 表示本次输出且符合 scope 的评论数。`, Example: ` dws drive comment list --node --format json dws drive comment list --node --all --format json`, Tool: listFileCommentsTool, Flags: []LeafFlag{ fileCommentNodeFlag(), fileCommentSpaceIDFlag(), {Name: "limit", Usage: "每页评论数,范围 1-200", Kind: LeafInt, Default: "200", Aliases: []string{"page-size"}, Bind: "maxResults"}, {Name: "cursor", Usage: "分页游标,取自上页 nextCursor", Bind: "nextToken", OmitEmpty: true, Trim: true}, {Name: "all", Usage: "自动拉取全部评论,最多 10 页 / 2000 条", Kind: LeafBool, Bind: "all"}, {Name: "scope", Usage: "评论范围: all(全部) / whole(全文) / partial(历史局部)", Default: "all", Bind: "scope", Trim: true, Enum: []string{"all", "whole", "partial"}}, }, Constraints: []LeafConstraint{ {Kind: LeafMutuallyExclusive, Flags: []string{"all", "cursor"}, Description: "--all 与 --cursor 互斥"}, {Kind: LeafMutuallyExclusive, Flags: []string{"all", "limit"}, Description: "--all 与显式 --limit/--page-size 互斥"}, {Kind: "custom", Flags: []string{"limit"}, Description: "--limit/--page-size 必须在 1-200 之间"}, {Kind: "custom", Flags: []string{"cursor"}, Description: "--cursor 必须是服务端返回的非负数字游标"}, }, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "drive", Name: "list_file_comments", CanonicalPath: "drive.list_file_comments", CLIPath: "drive comment list", PrimaryCLIPath: "drive comment list", }, Description: "查询钉盘普通预览文件评论,支持安全分页聚合和全文/局部范围过滤", DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "The CLI wraps doc-comment/list_file_comments with bounded auto-pagination, cursor anomaly guards, local anchor-scope filtering, and a stable output projection, so no single direct MCP interface represents the complete command contract.", }, Selection: contract.SelectionSpec{ AgentSummary: "查询 PDF、DOCX、XLSX 等钉盘普通预览文件上的评论", UseWhen: []string{ "用户要查看普通文件上的全文评论或历史高亮/矩形评论时", "需要从评论列表取得 commentId、作者、时间和统一 anchor,或用 --all 拉取完整列表时", }, AvoidWhen: []string{ "在线文字文档评论使用 dws doc comment list", "在线表格单元格评论使用 dws sheet comment list", }, Examples: []string{ "dws drive comment list --node --format json", "dws drive comment list --node --all --format json", }, }, }, Validate: validateFileCommentList, Call: runFileCommentList, }) createCmd := NewLeafCommand(LeafSpec{ Use: "create", Short: "创建普通文件全文评论", Long: `在钉盘普通预览文件上创建一条全文纯文本评论。当前不支持 @人、通知选项或 局部锚点;content 按服务端规则使用 UTF-16 长度计数,最多 2099。`, Example: ` dws drive comment create --node --content "请补充最终结论" --format json`, Tool: createFileCommentTool, Flags: []LeafFlag{ fileCommentNodeFlag(), fileCommentSpaceIDFlag(), {Name: "content", Usage: "全文评论内容,纯文本且 UTF-16 长度不超过 2099", Required: true, Bind: "content"}, }, Constraints: []LeafConstraint{ {Kind: "custom", Flags: []string{"content"}, Description: "--content 去除首尾空白后必须非空,且 UTF-16 长度不超过 2099"}, }, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "drive", Name: "create_file_comment", CanonicalPath: "drive.create_file_comment", CLIPath: "drive comment create", PrimaryCLIPath: "drive comment create", }, Description: "在钉盘普通预览文件上创建一条全文纯文本评论", DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "The CLI wraps doc-comment/create_file_comment with exact local content validation, runtime confirmation, and a stable flattened output projection, so no single direct MCP interface represents the complete command contract.", }, Selection: contract.SelectionSpec{ AgentSummary: "在 PDF、DOCX、XLSX 等钉盘普通预览文件上创建全文纯文本评论", UseWhen: []string{ "用户明确要求在普通文件上留下不绑定具体位置的评论时", }, AvoidWhen: []string{ "在线文字文档评论使用 dws doc comment create", "在线表格单元格评论使用 dws sheet comment create;当前普通文件评论不支持 @人或局部锚点", }, Examples: []string{ "dws drive comment create --node --content \"请补充最终结论\" --format json", }, }, }, Validate: validateFileCommentCreate, Call: runFileCommentCreate, }) commentCmd.AddCommand(listCmd, createCmd) return commentCmd } func validateFileCommentList(cmd *cobra.Command, _ []string) error { if err := validateFileCommentNodeSpace(cmd); err != nil { return err } limit, _ := cmd.Flags().GetInt("limit") if cmd.Flags().Changed("page-size") { limit, _ = cmd.Flags().GetInt("page-size") } if limit < 1 || limit > fileCommentMaxPageSize { return &CLIError{ Code: CodeInvalidParam, Message: fmt.Sprintf("--limit/--page-size 必须在 1-%d 之间", fileCommentMaxPageSize), } } if cursor, _ := cmd.Flags().GetString("cursor"); strings.TrimSpace(cursor) != "" { cursor = strings.TrimSpace(cursor) if !allASCIIDigits(cursor) { return &CLIError{Code: CodeInvalidParam, Message: "--cursor 必须是服务端返回的非负数字游标"} } if _, err := strconv.ParseInt(cursor, 10, 64); err != nil { return &CLIError{Code: CodeInvalidParam, Message: "--cursor 超出 64 位整数范围"} } } return nil } func validateFileCommentCreate(cmd *cobra.Command, _ []string) error { if err := validateFileCommentNodeSpace(cmd); err != nil { return err } content, _ := cmd.Flags().GetString("content") if strings.TrimSpace(content) == "" { return &CLIError{Code: CodeInvalidParam, Message: "--content 去除首尾空白后不能为空"} } length := fileCommentUTF16Length(content) if length > fileCommentMaxContentLength { return &CLIError{ Code: CodeInputTooLarge, Message: fmt.Sprintf("--content 最多 %d 个 UTF-16 代码单元,当前为 %d", fileCommentMaxContentLength, length), } } return nil } func validateFileCommentNodeSpace(cmd *cobra.Command) error { node := corecmd.EffectiveValue(cmd, fileCommentNodeFlag()) if !allASCIIDigits(node) { return nil } if spaceID := corecmd.EffectiveValue(cmd, fileCommentSpaceIDFlag()); spaceID == "" { return &CLIError{ Code: CodeInvalidParam, Message: "--node 为数字 dentry ID 时必须同时提供 --space-id", } } return nil } func fileCommentUTF16Length(value string) int { length := 0 for _, r := range value { length++ if r > 0xffff { length++ } } return length } func allASCIIDigits(value string) bool { if value == "" { return false } for _, r := range value { if r < '0' || r > '9' { return false } } return true } func runFileCommentList(cmd *cobra.Command, tool string, args map[string]any) error { fetchAll, _ := args["all"].(bool) scope, _ := args["scope"].(string) if scope == "" { scope = "all" } request := fileCommentMCPArgs(args) if fetchAll { request["maxResults"] = fileCommentMaxPageSize delete(request, "nextToken") } if deps != nil && deps.Caller != nil && deps.Caller.DryRun() { return callMCPToolOnServer(fileCommentServer, tool, request) } if !fetchAll { page, err := fetchFileCommentPage(tool, request) if err != nil { return err } if err := validateFileCommentNextCursor(page, stringArg(request, "nextToken")); err != nil { return err } page.comments = filterFileCommentsByScope(page.comments, scope) return output.WriteCommandPayload(cmd, fileCommentListPayload(page, scope), output.FormatJSON) } var aggregate fileCommentPage aggregate.comments = make([]map[string]any, 0) seenCursors := map[string]bool{} currentCursor := "" for pageNumber := 0; pageNumber < fileCommentMaxAutoPages; pageNumber++ { if currentCursor == "" { delete(request, "nextToken") } else { request["nextToken"] = currentCursor } page, err := fetchFileCommentPage(tool, request) if err != nil { return err } if pageNumber == 0 { aggregate.nodeID = page.nodeID aggregate.total = page.total } aggregate.comments = append(aggregate.comments, filterFileCommentsByScope(page.comments, scope)...) if !page.hasMore { aggregate.hasMore = false aggregate.nextCursor = "" return output.WriteCommandPayload(cmd, fileCommentListPayload(aggregate, scope), output.FormatJSON) } if err := validateFileCommentNextCursor(page, currentCursor); err != nil { return err } if seenCursors[page.nextCursor] { return fileCommentPaginationError("分页游标发生循环,结果可能不完整") } seenCursors[page.nextCursor] = true currentCursor = page.nextCursor } return fileCommentPaginationError(fmt.Sprintf("自动翻页达到 %d 页上限但服务端仍返回 hasMore=true,结果可能不完整", fileCommentMaxAutoPages)) } func runFileCommentCreate(cmd *cobra.Command, tool string, args map[string]any) error { request := fileCommentMCPArgs(args) if deps != nil && deps.Caller != nil && deps.Caller.DryRun() { return callMCPToolOnServer(fileCommentServer, tool, request) } raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request) if err != nil { return err } payload, err := decodeFileCommentPayload(tool, raw) if err != nil { return err } nodeID, ok := nonEmptyStringField(payload, "fileId") if !ok { return invalidFileCommentResponse(tool, "缺少 fileId", nil) } comment, ok := payload["comment"].(map[string]any) if !ok { return invalidFileCommentResponse(tool, "缺少 comment 对象", nil) } if err := validateFileCommentItem(tool, "comment", comment); err != nil { return err } out := map[string]any{"nodeId": nodeID} for key, value := range projectFileComment(comment) { out[key] = value } return output.WriteCommandPayload(cmd, out, output.FormatJSON) } func fileCommentMCPArgs(args map[string]any) map[string]any { out := make(map[string]any, len(args)) for key, value := range args { switch key { case "all", "scope": continue default: out[key] = value } } return out } func fetchFileCommentPage(tool string, request map[string]any) (fileCommentPage, error) { raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request) if err != nil { return fileCommentPage{}, err } payload, err := decodeFileCommentPayload(tool, raw) if err != nil { return fileCommentPage{}, err } nodeID, ok := nonEmptyStringField(payload, "fileId") if !ok { return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 fileId", nil) } total, ok := payload["total"] if !ok { return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 total", nil) } hasMore, ok := payload["hasMore"].(bool) if !ok { return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少布尔字段 hasMore", nil) } nextCursor := "" if value, exists := payload["nextToken"]; exists && value != nil { var stringValue bool nextCursor, stringValue = value.(string) if !stringValue { return fileCommentPage{}, invalidFileCommentResponse(tool, "nextToken 不是字符串", nil) } } rawItems, exists := payload["items"] if !exists { return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 items", nil) } items, ok := rawItems.([]any) if rawItems == nil { items = []any{} ok = true } if !ok { return fileCommentPage{}, invalidFileCommentResponse(tool, "items 不是数组", nil) } comments := make([]map[string]any, 0, len(items)) for index, item := range items { comment, ok := item.(map[string]any) if !ok { return fileCommentPage{}, invalidFileCommentResponse(tool, fmt.Sprintf("items[%d] 不是对象", index), nil) } if err := validateFileCommentItem(tool, fmt.Sprintf("items[%d]", index), comment); err != nil { return fileCommentPage{}, err } comments = append(comments, projectFileComment(comment)) } return fileCommentPage{ nodeID: nodeID, total: total, hasMore: hasMore, nextCursor: strings.TrimSpace(nextCursor), comments: comments, }, nil } func decodeFileCommentPayload(tool, raw string) (map[string]any, error) { if strings.TrimSpace(raw) == "" { return nil, invalidFileCommentResponse(tool, "返回为空", nil) } var payload map[string]any if err := json.Unmarshal([]byte(raw), &payload); err != nil { return nil, invalidFileCommentResponse(tool, "返回不是有效 JSON", err) } for depth := 0; depth < 2; depth++ { if _, ok := payload["fileId"]; ok { break } var nested map[string]any for _, key := range []string{"result", "data"} { if value, ok := payload[key].(map[string]any); ok { nested = value break } } if nested == nil { break } payload = nested } return payload, nil } func projectFileComment(comment map[string]any) map[string]any { out := map[string]any{} for _, key := range []string{"commentId", "parentCommentId", "content", "createdAt", "updatedAt", "options", "anchor"} { if value, ok := comment[key]; ok { out[key] = value } } if value, ok := comment["commentCustomType"]; ok { out["customType"] = value } creator := map[string]any{} for source, target := range map[string]string{ "creatorId": "userId", "creatorName": "name", "creatorAvatar": "avatar", } { if value, ok := comment[source]; ok { creator[target] = value } } if len(creator) > 0 { out["creator"] = creator } return out } func validateFileCommentItem(tool, path string, comment map[string]any) error { if _, ok := nonEmptyStringField(comment, "commentId"); !ok { return invalidFileCommentResponse(tool, path+" 缺少 commentId", nil) } if _, ok := comment["anchor"].(map[string]any); !ok { return invalidFileCommentResponse(tool, path+" 缺少 anchor 对象", nil) } return nil } func filterFileCommentsByScope(comments []map[string]any, scope string) []map[string]any { if scope == "" || scope == "all" { return comments } out := make([]map[string]any, 0, len(comments)) for _, comment := range comments { anchor, _ := comment["anchor"].(map[string]any) commentScope, _ := anchor["scope"].(string) if commentScope == scope { out = append(out, comment) } } return out } func fileCommentListPayload(page fileCommentPage, scope string) map[string]any { comments := page.comments if comments == nil { comments = make([]map[string]any, 0) } nextCursor := any(nil) if page.nextCursor != "" { nextCursor = page.nextCursor } return map[string]any{ "nodeId": page.nodeID, "total": page.total, "count": len(page.comments), "hasMore": page.hasMore, "nextCursor": nextCursor, "complete": !page.hasMore, "scope": scope, "comments": comments, } } func validateFileCommentNextCursor(page fileCommentPage, currentCursor string) error { if !page.hasMore { return nil } if page.nextCursor == "" { return fileCommentPaginationError("服务端返回 hasMore=true 但 nextCursor 为空,结果可能不完整") } if !allASCIIDigits(page.nextCursor) { return fileCommentPaginationError("服务端返回的 nextCursor 不是非负数字游标,结果可能不完整") } if _, err := strconv.ParseInt(page.nextCursor, 10, 64); err != nil { return fileCommentPaginationError("服务端返回的 nextCursor 超出 64 位整数范围,结果可能不完整") } if page.nextCursor == currentCursor { return fileCommentPaginationError("服务端分页游标未前进,结果可能不完整") } return nil } func fileCommentPaginationError(message string) error { return &CLIError{ Code: CodeContentTruncated, Message: message, Suggestion: "请稍后重试,或去掉 --all 后使用服务端返回的 --cursor 分页读取", Operation: fileCommentServer + "/" + listFileCommentsTool, } } func invalidFileCommentResponse(tool, reason string, cause error) error { return &CLIError{ Code: CodeMCPToolError, Message: fmt.Sprintf("%s 返回结构异常:%s", tool, reason), Suggestion: "请确认 doc-comment MCP 与当前 DWS 契约一致", Operation: fileCommentServer + "/" + tool, Cause: cause, } } func nonEmptyStringField(payload map[string]any, key string) (string, bool) { value, ok := payload[key].(string) value = strings.TrimSpace(value) return value, ok && value != "" } func stringArg(args map[string]any, key string) string { value, _ := args[key].(string) return strings.TrimSpace(value) }