Files
dingtalk-workspace-cli/internal/shortcut/doc/review_shortcuts.go
T

380 lines
14 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf16"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var Review = shortcut.Shortcut{
Service: "doc", Command: "+review", Product: productComment,
Description: "聚合未解决评论、引用原文和块上下文",
Intent: "当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+review", "聚合未解决评论、引用原文和块上下文",
"当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。",
[]string{`dws doc +review --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +review --node <DOC_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
node := rt.Str("node")
comments, err := rt.CallMCPData(productComment, "list_comments", map[string]any{"nodeId": node, "resolveStatus": "unresolved"})
if err != nil {
return err
}
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": node, "format": "element"})
if err != nil {
return err
}
items := projectReviewComments(comments, blocks)
global, inline := 0, 0
for _, item := range items {
if item["blockId"] == "" {
global++
} else {
inline++
}
}
return rt.Output(map[string]any{"status": "unresolved", "counts": map[string]any{"total": len(items), "global": global, "inline": inline}, "comments": items})
},
}
var CommentUpdate = shortcut.Shortcut{
Service: "doc", Command: "+comment-update", Product: productComment,
Description: "更新指定文档评论正文和 mention",
Intent: "当用户要修改一条已有评论的文字内容或 @ 用户列表,且已知 commentKey 时使用;不会创建回复或改变解决状态。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+comment-update", "更新指定文档评论正文和 mention",
"当用户要修改一条已有评论的文字内容或 @ 用户列表,且已知 commentKey 时使用;不会创建回复或改变解决状态。",
[]string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "更新后的评论正文", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Tips: []string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key"), "content": rt.Str("content")}
if rt.Changed("mention") {
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
return rt.CallMCP("update_comment", params)
},
}
var CommentDelete = shortcut.Shortcut{
Service: "doc", Command: "+comment-delete", Product: productComment,
Description: "永久删除指定文档评论",
Intent: "当用户明确要求永久删除某条文档评论,且已核对 node 与 commentKey 时使用;不可用于标记 resolved。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docContract("+comment-delete", "永久删除指定文档评论",
"当用户明确要求永久删除某条文档评论,且已核对 node 与 commentKey 时使用;不可用于标记 resolved。",
[]string{`dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
},
Tips: []string{`dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key")}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.comment_delete", map[string]any{"executed": false, "params": params}))
}
return rt.CallMCP("delete_comment", params)
},
}
func validateCommentCreate(rt *shortcut.RuntimeContext) error {
hasBlock := rt.Str("block-id") != ""
hasOffsets := rt.Changed("start") || rt.Changed("end")
if hasBlock != hasOffsets || (hasOffsets && (!rt.Changed("start") || !rt.Changed("end"))) {
return apperrors.NewValidation("--block-id、--start、--end 必须一起提供")
}
if hasBlock && rt.Int("end") <= rt.Int("start") {
return apperrors.NewValidation("--end 必须大于 --start")
}
if hasBlock && rt.Str("selection") != "" {
return apperrors.NewValidation("--selection 与 block-id/start/end 高级通道不能同时提供")
}
return nil
}
func executeCommentCreate(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "content": rt.Str("content")}
if rt.Changed("mention") {
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
if rt.Str("block-id") != "" {
blockID, start, end := rt.Str("block-id"), rt.Int("start"), rt.Int("end")
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "blockId": blockID, "format": "element"})
if err != nil {
return err
}
blockText := map[string]string{}
collectBlockText(blocks, "", blockText)
selectedText, ok := sliceUTF16Range(blockText[blockID], start, end)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("INVALID_SELECTION_RANGE: block %s 的 UTF-16 范围 [%d,%d) 无效", blockID, start, end))
}
if supplied := rt.Str("selected-text"); supplied != "" && supplied != selectedText {
return apperrors.NewValidation("SELECTED_TEXT_MISMATCH: --selected-text 与 block/start/end 对应的真实原文不一致")
}
params["blockId"], params["start"], params["end"], params["selectedText"] = blockID, start, end, selectedText
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
}
if selection := rt.Str("selection"); selection != "" {
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
if err != nil {
return err
}
matches := findSelectionMatches(blocks, selection)
if len(matches) != 1 {
candidates := make([]map[string]any, 0, len(matches))
for _, match := range matches {
candidates = append(candidates, map[string]any{"blockId": match.blockID, "excerpt": match.text})
}
return apperrors.NewValidation(fmt.Sprintf("AMBIGUOUS_SELECTION: selection 需要唯一匹配,实际 %d 处;候选=%v", len(matches), candidates))
}
params["blockId"], params["start"], params["end"], params["selectedText"] = matches[0].blockID, matches[0].start, matches[0].end, matches[0].selected
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
}
return executeCommentWrite(rt, "create_comment", "global", params)
}
func normalizeMentionUserIDs(values []string) ([]string, error) {
if len(values) == 1 && strings.HasPrefix(strings.TrimSpace(values[0]), "[") {
var decoded []any
decoder := json.NewDecoder(strings.NewReader(values[0]))
decoder.UseNumber()
if err := decoder.Decode(&decoded); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--mention JSON 数组解析失败: %v", err))
}
values = make([]string, 0, len(decoded))
for _, value := range decoded {
switch typed := value.(type) {
case string:
values = append(values, typed)
case json.Number:
values = append(values, typed.String())
default:
return nil, apperrors.NewValidation("--mention JSON 数组只接受字符串或数字 uid")
}
}
}
values = stringSliceNonEmpty(values)
if len(values) == 0 {
return nil, apperrors.NewValidation("--mention 至少包含一个非空用户 uid")
}
return values, nil
}
func executeCommentWrite(rt *shortcut.RuntimeContext, tool, commentType string, params map[string]any) error {
result, err := rt.CallMCPWriteData(productComment, tool, params)
if err != nil {
return docUnknownWriteError("doc.comment_create", tool, rt.Str("node"), err)
}
return rt.Output(docEnvelope("doc.comment_create", map[string]any{
"nodeId": rt.Str("node"), "commentType": commentType, "result": result,
}, map[string]any{"name": tool, "status": "success"}))
}
func sliceUTF16Range(text string, start, end int) (string, bool) {
units := utf16.Encode([]rune(text))
if start < 0 || end <= start || end > len(units) {
return "", false
}
isHigh := func(value uint16) bool { return value >= 0xD800 && value <= 0xDBFF }
isLow := func(value uint16) bool { return value >= 0xDC00 && value <= 0xDFFF }
if start > 0 && start < len(units) && isHigh(units[start-1]) && isLow(units[start]) {
return "", false
}
if end > 0 && end < len(units) && isHigh(units[end-1]) && isLow(units[end]) {
return "", false
}
return string(utf16.Decode(units[start:end])), true
}
type selectionMatch struct {
blockID string
text, selected string
start, end int
}
func findSelectionMatches(value any, selection string) []selectionMatch {
if selection == "" {
return nil
}
var prefix, suffix string
omitted := false
if parts := strings.SplitN(selection, "...", 2); len(parts) == 2 {
prefix, suffix = parts[0], parts[1]
omitted = true
}
var out []selectionMatch
appendMatch := func(blockID, text string, startByte, endByte int) {
out = append(out, selectionMatch{
blockID: blockID, text: text, selected: text[startByte:endByte],
start: utf16Length(text[:startByte]), end: utf16Length(text[:endByte]),
})
}
var walk func(any, string)
walk = func(current any, inheritedID string) {
switch typed := current.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
if text, ok := typed["text"].(string); ok && blockID != "" {
if !omitted {
for searchStart := 0; searchStart < len(text); {
index := strings.Index(text[searchStart:], selection)
if index < 0 {
break
}
startByte := searchStart + index
endByte := startByte + len(selection)
appendMatch(blockID, text, startByte, endByte)
searchStart = startByte + 1
}
} else {
prefixStarts := []int{0}
if prefix != "" {
prefixStarts = nil
for searchStart := 0; searchStart < len(text); {
index := strings.Index(text[searchStart:], prefix)
if index < 0 {
break
}
startByte := searchStart + index
prefixStarts = append(prefixStarts, startByte)
searchStart = startByte + 1
}
}
for _, startByte := range prefixStarts {
suffixSearch := startByte + len(prefix)
if suffix == "" {
if startByte < len(text) {
appendMatch(blockID, text, startByte, len(text))
}
continue
}
for suffixSearch <= len(text) {
index := strings.Index(text[suffixSearch:], suffix)
if index < 0 {
break
}
suffixStart := suffixSearch + index
appendMatch(blockID, text, startByte, suffixStart+len(suffix))
suffixSearch = suffixStart + 1
}
}
}
}
for _, child := range typed {
walk(child, blockID)
}
case []any:
for _, child := range typed {
walk(child, inheritedID)
}
}
}
walk(value, "")
return out
}
func utf16Length(value string) int {
return len(utf16.Encode([]rune(value)))
}
func projectReviewComments(comments, blocks map[string]any) []map[string]any {
blockText := map[string]string{}
collectBlockText(blocks, "", blockText)
var out []map[string]any
var walk func(any)
walk = func(value any) {
switch typed := value.(type) {
case map[string]any:
key := firstString(typed, "commentKey", "commentId")
if key != "" {
blockID := firstString(typed, "blockId")
selectedText := firstString(typed, "selectedText", "quote")
// list_comments currently omits blockId for inline comments but
// includes isGlobal=false plus the selected quote. Resolve the
// block deterministically when that quote is unique.
if blockID == "" && selectedText != "" {
if matches := findSelectionMatches(blocks, selectedText); len(matches) == 1 {
blockID = matches[0].blockID
}
}
out = append(out, map[string]any{
"commentKey": key,
"content": firstString(typed, "content", "text"),
"selectedText": selectedText,
"blockId": blockID,
"context": blockText[blockID],
"replies": typed["replies"],
})
return
}
for _, child := range typed {
walk(child)
}
case []any:
for _, child := range typed {
walk(child)
}
}
}
walk(comments)
return out
}
func collectBlockText(value any, inheritedID string, out map[string]string) {
switch typed := value.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
if text, ok := typed["text"].(string); ok && blockID != "" {
out[blockID] = text
}
for _, child := range typed {
collectBlockText(child, blockID, out)
}
case []any:
for _, child := range typed {
collectBlockText(child, inheritedID, out)
}
}
}
func firstString(values map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := values[key].(string); ok {
return text
}
}
return ""
}
func init() {
shortcut.Register(Review, CommentUpdate, CommentDelete)
}