feat: support files in drive +add-comment (#975)

* feat: support markdown files in drive +add-comment

Change-Id: Id9a87706a1e43756d8142637be9ec1e0748d4ddf

* fix: use markdown file comment anchor placeholder

Change-Id: Ifffc4cdd963c13e53f4cad154aebe11ae309df9e

* fix: gate drive file comments by supported extensions

Change-Id: Ie6c7f38dbbea1f87a81600da71180627b53a2355
This commit is contained in:
wangweiming-01
2026-05-21 21:40:27 +08:00
committed by GitHub
parent d3fbc88527
commit e54220ade1
7 changed files with 652 additions and 26 deletions

View File

@@ -47,6 +47,34 @@ const defaultLocateDocLimit = 10
// with `drive file.comments create_v2` against a fresh docx.
const maxCommentTotalRunes = 10000
// The file comment API treats supported Drive file comments as full-file
// comments in the UI, but currently rejects an empty anchor.block_id for file
// targets. TODO: remove this placeholder after the API accepts omitting
// anchor.block_id for file full comments.
const fileFullCommentAnchorBlockID = "test"
// File comments are enabled only for extensions verified to render correctly in
// the Lark file preview comment UI. Keep this list conservative: PDF, docx, and
// xlsx currently accept the API request but display poorly in the page.
var supportedFileCommentExtensions = []string{
".md",
".txt",
".json",
".csv",
".go",
".js",
".py",
".pptx",
".png",
".jpg",
".jpeg",
".zip",
".mp3",
".mp4",
}
var supportedFileCommentExtensionSet = newSupportedFileCommentExtensionSet(supportedFileCommentExtensions)
type commentDocRef struct {
Kind string
Token string
@@ -93,17 +121,18 @@ const (
var DriveAddComment = common.Shortcut{
Service: "drive",
Command: "+add-comment",
Description: "Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides",
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
Risk: "write",
Scopes: []string{
"drive:drive.metadata:readonly",
"docx:document:readonly",
"docs:document.comment:create",
"docs:document.comment:write_only",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet", "slides"}},
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
{Name: "content", Desc: "reply_elements JSON string", Required: true},
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
@@ -145,7 +174,6 @@ var DriveAddComment = common.Shortcut{
}
return nil
}
selection := runtime.Str("selection-with-ellipsis")
blockID := strings.TrimSpace(runtime.Str("block-id"))
if strings.TrimSpace(selection) != "" && blockID != "" {
@@ -156,6 +184,9 @@ var DriveAddComment = common.Shortcut{
}
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
if docRef.Kind == "file" {
return validateFileCommentMode(mode, "")
}
if mode == commentModeLocal && docRef.Kind == "doc" {
return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
}
@@ -217,6 +248,33 @@ var DriveAddComment = common.Shortcut{
Body(commentBody).
Set("file_token", resolvedToken)
}
if resolvedKind == "file" {
commentBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
desc := "2-step orchestration: verify supported file metadata -> create file comment"
verifyStep := "[1]"
createStep := "[2]"
if isWiki {
desc = "3-step orchestration: resolve wiki -> verify supported file metadata -> create file comment"
verifyStep = "[2]"
createStep = "[3]"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/metas/batch_query").
Desc(verifyStep+" Read file metadata and verify the title extension is supported").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": resolvedToken,
"doc_type": "file",
},
},
}).
POST("/open-apis/drive/v1/files/:file_token/new_comments").
Desc(createStep+" Create file full comment").
Body(commentBody).
Set("file_token", resolvedToken)
}
// Doc/docx comment dry-run.
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
@@ -317,6 +375,9 @@ var DriveAddComment = common.Shortcut{
if target.FileType == "slides" {
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
}
if target.FileType == "file" {
return executeFileComment(runtime, target)
}
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
@@ -421,6 +482,9 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
if token, ok := extractURLToken(raw, "/sheets/"); ok {
return commentDocRef{Kind: "sheet", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/file/"); ok {
return commentDocRef{Kind: "file", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/slides/"); ok {
return commentDocRef{Kind: "slides", Token: token}, nil
}
@@ -431,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet/slides", raw)
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw)
}
if strings.ContainsAny(raw, "/?#") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
@@ -440,7 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
// Bare token: --type is required.
docType = strings.TrimSpace(docType)
if docType == "" {
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet, slides)")
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)")
}
return commentDocRef{Kind: docType, Token: raw}, nil
}
@@ -451,9 +515,16 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
return resolvedCommentTarget{}, err
}
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" && docRef.Kind != "slides" {
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
if mode == commentModeLocal {
switch docRef.Kind {
case "doc":
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
case "file":
if err := validateFileCommentMode(mode, ""); err != nil {
return resolvedCommentTarget{}, err
}
}
}
return resolvedCommentTarget{
DocID: docRef.Token,
@@ -507,11 +578,24 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
WikiToken: docRef.Token,
}, nil
}
if objType == "file" {
if err := validateFileCommentMode(mode, objType); err != nil {
return resolvedCommentTarget{}, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return resolvedCommentTarget{
DocID: objToken,
FileToken: objToken,
FileType: "file",
ResolvedBy: "wiki",
WikiToken: docRef.Token,
}, nil
}
if mode == commentModeLocal && objType != "docx" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
}
if mode == commentModeFull && objType != "docx" && objType != "doc" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet/slides", objType)
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -718,6 +802,10 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
"sheet_col": sheet.Col,
"sheet_row": sheet.Row,
}
} else if fileType == "file" {
body["anchor"] = map[string]interface{}{
"block_id": fileFullCommentAnchorBlockID,
}
} else if strings.TrimSpace(blockID) != "" {
body["anchor"] = map[string]interface{}{
"block_id": blockID,
@@ -809,6 +897,107 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
}
func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": fileToken,
"doc_type": "file",
},
},
},
)
if err != nil {
return "", err
}
metas := common.GetSlice(data, "metas")
if len(metas) == 0 {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
}
meta, ok := metas[0].(map[string]interface{})
if !ok {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
}
return common.GetString(meta, "title"), nil
}
func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken string) (string, string, error) {
title, err := fetchCommentTargetFileTitle(runtime, fileToken)
if err != nil {
return "", "", err
}
extension := fileCommentExtension(title)
if isSupportedFileCommentExtension(extension) {
return title, extension, nil
}
if strings.TrimSpace(title) == "" {
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
"drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title",
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
}
extensionLabel := extension
if extensionLabel == "" {
extensionLabel = "no extension"
}
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel),
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
}
func fileCommentExtension(title string) string {
title = strings.TrimSpace(title)
idx := strings.LastIndex(title, ".")
if idx == 0 {
extension := strings.ToLower(title)
if isSupportedFileCommentExtension(extension) {
return extension
}
return ""
}
if idx < 0 || idx == len(title)-1 {
return ""
}
return strings.ToLower(title[idx:])
}
func isSupportedFileCommentExtension(extension string) bool {
_, ok := supportedFileCommentExtensionSet[strings.TrimSpace(extension)]
return ok
}
func supportedFileCommentExtensionsText() string {
return strings.Join(supportedFileCommentExtensions, ", ")
}
func newSupportedFileCommentExtensionSet(extensions []string) map[string]struct{} {
set := make(map[string]struct{}, len(extensions))
for _, extension := range extensions {
set[extension] = struct{}{}
}
return set
}
func validateFileCommentMode(mode commentMode, resolvedObjType string) error {
if mode != commentModeLocal {
return nil
}
if resolvedObjType != "" {
return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
}
return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis")
}
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
@@ -849,6 +1038,48 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
return nil
}
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return err
}
title, extension, err := ensureSupportedFileCommentTarget(runtime, target.FileToken)
if err != nil {
return err
}
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
requestBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension)
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
out := map[string]interface{}{
"comment_id": data["comment_id"],
"doc_id": target.DocID,
"file_token": target.FileToken,
"file_type": "file",
"file_name": title,
"file_extension": extension,
"resolved_by": target.ResolvedBy,
"comment_mode": string(commentModeFull),
}
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
out["created_at"] = createdAt
}
if target.WikiToken != "" {
out["wiki_token"] = target.WikiToken
}
runtime.Out(out, nil)
return nil
}
func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {

View File

@@ -105,6 +105,13 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "doc",
wantToken: "docToken",
},
{
name: "raw token with type file",
input: "fileToken",
docType: "file",
wantKind: "file",
wantToken: "fileToken",
},
{
name: "raw token without type",
input: "xxxxxx",
@@ -122,6 +129,12 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "slides",
wantToken: "pres_123",
},
{
name: "file url",
input: "https://example.larksuite.com/file/boxcn123?from=share",
wantKind: "file",
wantToken: "boxcn123",
},
{
name: "unsupported url",
input: "https://example.com/not-a-doc",
@@ -545,6 +558,29 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
}
}
func TestBuildCommentCreateV2RequestFile(t *testing.T) {
t.Parallel()
replyElements := []map[string]interface{}{
{
"type": "text",
"text": "README comment",
},
}
got := buildCommentCreateV2Request("file", "", "", replyElements, nil)
if got["file_type"] != "file" {
t.Fatalf("expected file_type file, got %#v", got["file_type"])
}
anchor, ok := got["anchor"].(map[string]interface{})
if !ok {
t.Fatalf("expected anchor map, got %#v", got["anchor"])
}
if blockID, ok := anchor["block_id"].(string); !ok || blockID != fileFullCommentAnchorBlockID {
t.Fatalf("expected file anchor.block_id %q, got %#v", fileFullCommentAnchorBlockID, anchor["block_id"])
}
}
func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
t.Parallel()
@@ -906,6 +942,34 @@ func TestSlidesCommentValidateCompoundBlockID(t *testing.T) {
}
}
func TestFileCommentValidateRejectsBlockID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "blk_123",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") {
t.Fatalf("expected file local-comment rejection, got: %v", err)
}
}
func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--selection-with-ellipsis", "something",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") {
t.Fatalf("expected file local-comment rejection, got: %v", err)
}
}
// ── Slides comment execute tests ────────────────────────────────────────────
func TestSlidesCommentExecuteSuccess(t *testing.T) {
@@ -1116,6 +1180,146 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
}
}
func TestFileCommentExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{
map[string]interface{}{"title": "README.txt"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/fileToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"comment_id": "fileComment123", "created_at": 1700000000},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"请补充 README 示例"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fileComment123") {
t.Fatalf("stdout missing comment_id: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" {
t.Fatalf("stdout file_type = %q, want file\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "file_name", "data.file_name"); got != "README.txt" {
t.Fatalf("stdout file_name = %q, want README.txt\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "file_extension", "data.file_extension"); got != ".txt" {
t.Fatalf("stdout file_extension = %q, want .txt\nstdout:\n%s", got, stdout.String())
}
}
func TestFileCommentExecuteRejectsUnsupportedFileType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{
map[string]interface{}{"title": "notes.pdf"},
},
},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "does not support comments for this Drive file type yet") {
t.Fatalf("expected unsupported file comment type error, got: %v", err)
}
if !strings.Contains(err.Error(), "notes.pdf") {
t.Fatalf("expected error to mention unsupported title, got: %v", err)
}
}
func TestFileCommentExecuteRejectsUnexpectedMetadataFormat(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{"unexpected"},
},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "unexpected metadata format") {
t.Fatalf("expected unexpected metadata format error, got: %v", err)
}
}
func TestFileCommentSupportedExtensions(t *testing.T) {
t.Parallel()
supported := []string{
"README.md",
"notes.TXT",
"data.json",
"table.csv",
"main.go",
"app.js",
"script.py",
"slides.pptx",
"image.png",
"photo.jpg",
"photo.jpeg",
".md",
"archive.zip",
"audio.mp3",
"video.mp4",
}
for _, title := range supported {
extension := fileCommentExtension(title)
if !isSupportedFileCommentExtension(extension) {
t.Fatalf("%s extension %q should be supported", title, extension)
}
}
unsupported := []string{
"report.pdf",
"word.docx",
"sheet.xlsx",
"unknown.bin",
"no-extension",
".gitignore",
}
for _, title := range unsupported {
extension := fileCommentExtension(title)
if isSupportedFileCommentExtension(extension) {
t.Fatalf("%s extension %q should not be supported", title, extension)
}
}
if extension := fileCommentExtension(".gitignore"); extension != "" {
t.Fatalf("dotfile extension = %q, want empty", extension)
}
}
// ── DryRun coverage ─────────────────────────────────────────────────────────
func TestDryRunSheetDirectURL(t *testing.T) {
@@ -1346,6 +1550,43 @@ func TestDryRunDocxFullComment(t *testing.T) {
}
}
func TestDryRunFileDirectURL(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "verify supported file metadata") {
t.Fatalf("dry-run output missing supported file metadata verification step: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
if len(api) != 2 {
t.Fatalf("expected 2 dry-run api calls, got %d\nstdout:\n%s", len(api), stdout.String())
}
verifyCall := mustMapValue(t, api[0], "api[0]")
createCall := mustMapValue(t, api[1], "api[1]")
verifyBody := mustMapValue(t, verifyCall["body"], "api[0].body")
createBody := mustMapValue(t, createCall["body"], "api[1].body")
requestDocs := mustSliceValue(t, verifyBody["request_docs"], "api[0].body.request_docs")
requestDoc := mustMapValue(t, requestDocs[0], "api[0].body.request_docs[0]")
if got := mustStringField(t, requestDoc, "doc_type", "api[0].body.request_docs[0].doc_type"); got != "file" {
t.Fatalf("metadata query doc_type = %q, want file\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, createBody, "file_type", "api[1].body.file_type"); got != "file" {
t.Fatalf("comment create file_type = %q, want file\nstdout:\n%s", got, stdout.String())
}
anchor := mustMapValue(t, createBody["anchor"], "api[1].body.anchor")
if got := mustStringField(t, anchor, "block_id", "api[1].body.anchor.block_id"); got != fileFullCommentAnchorBlockID {
t.Fatalf("comment create anchor.block_id = %q, want %q\nstdout:\n%s", got, fileFullCommentAnchorBlockID, stdout.String())
}
}
// ── resolveCommentTarget coverage ───────────────────────────────────────────
func TestResolveWikiToDocxFullComment(t *testing.T) {
@@ -1397,7 +1638,7 @@ func TestResolveWikiToUnsupportedType(t *testing.T) {
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet/slides") {
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
t.Fatalf("expected unsupported type error, got: %v", err)
}
}

View File

@@ -130,8 +130,8 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`sheet` 使用 `<sheetId>!<cell>``slides` 使用 `<slide-block-type>!<xml-id>`,且都支持最终解析到对应类型的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`sheet` 使用 `<sheetId>!<cell>``slides` 使用 `<slide-block-type>!<xml-id>`,且都支持最终解析到对应类型的 wiki URLDrive file 不支持局部评论 |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | `file_token` | 同添加评论 |
@@ -139,15 +139,16 @@ Drive Folder (云空间文件夹)
### 评论能力边界(关键!)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;不同文档类型的支持范围与参数格式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;`docx` 支持文本定位或 block id`sheet` 支持 `<sheetId>!<cell>``slides` 支持 `<slide-block-type>!<xml-id>`wiki URL 解析到这些类型时也支持对应局部评论。Drive file 本次只支持全文评论,不支持局部评论
- Drive file 评论仅支持白名单扩展名:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。`.pdf`、`.docx`、`.xlsx` 等未在白名单内的普通文件暂不支持CLI 会直接报错提示当前还不支持这种类型的评论。
- Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论;具体参数和定位方式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
- 评论写入内容(添加评论、回复评论、编辑回复)里的文本不能直接出现 `<`、`>`;提交前必须先转义:`<` -> `&lt;``>` -> `&gt;`。
- 使用 `drive +add-comment` 时shortcut 会对 `type=text` 的文本元素自动做上述转义兜底;如果直接调用 `drive file.comments create_v2`、`drive file.comment.replys create`、`drive file.comment.replys update`,则需要在请求里自行传入已转义的内容。
- 如果 wiki 解析后不是 `doc`/`docx`/`sheet`/`slides`,不要用 `+add-comment`。
- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`,不要用 `+add-comment`。
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。
### 评论查询与统计口径(关键!)
@@ -265,7 +266,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/file/sheet/slides, also supports wiki URL resolving to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only |
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
给文档、电子表格或飞书幻灯片添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments``create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL仅全文评论、sheet URL、slides URL也支持传最终可解析为 doc/docx/sheet/slides 的 wiki URL。
给文档、受支持的 Drive 普通文件、电子表格或飞书幻灯片添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments``create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL仅全文评论Drive file URL/token**仅支持白名单扩展名,且只支持全文评论**)、sheet URL、slides URL也支持传最终可解析为 doc/docx/file/sheet/slides 的 wiki URL。
## 命令
@@ -24,13 +24,24 @@ lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--content '[{"type":"text","text":"这里需要一段全文评论"}]'
# 给受支持的 Drive 普通文件添加全文评论
# 注意CLI 会先查询 drive metas只有白名单扩展名才允许评论
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/file/<FILE_TOKEN>" \
--content '[{"type":"text","text":"请补充文件说明"}]'
# 裸 token 也支持,但必须显式声明 --type file
lark-cli drive +add-comment \
--doc "<FILE_TOKEN>" --type file \
--content '[{"type":"text","text":"请补充目录说明"}]'
# 给 docx 文档的指定 block 添加局部评论block_id 可通过 docs +fetch --api-version v2 --detail with-ids 获取)
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请补充流程说明"}]'
# wiki 链接也支持局部评论,但解析结果必须是 docx
# wiki 链接也支持局部评论解析结果可以是 docx/sheet/slidesblock-id 格式按目标类型传
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--block-id "<BLOCK_ID>" \
@@ -128,8 +139,8 @@ lark-cli drive +add-comment \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--doc` | 是 | 文档 URL / token、sheet / slides URL或可解析到 `doc`/`docx`/`sheet`/`slides` 的 wiki URL |
| `--type` | 裸 token 时必填 | 文档类型:`doc``docx``sheet``slides`。URL 输入时自动识别,无需传 |
| `--doc` | 是 | 文档 URL / token、file / sheet / slides URL或可解析到 `doc`/`docx`/`file`/`sheet`/`slides` 的 wiki URL |
| `--type` | 裸 token 时必填 | 文档类型:`doc``docx``file``sheet``slides`。URL 输入时自动识别,无需传 |
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论(不适用于 sheet |
| `--block-id` | 局部评论时必填 | 目标块 ID可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。**Sheet 评论**:格式为 `<sheetId>!<cell>`(如 `a281f9!D6` |
@@ -138,7 +149,10 @@ lark-cli drive +add-comment \
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
- **Review 场景优先局部评论**:审阅、校对、逐条指出问题时,必须先尝试定位到具体 block / 单元格 / slide 元素,并逐问题创建局部评论;不要把所有问题合并成一条全文评论。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL以及最终可解析为 `doc`/`docx` 的 wiki URL。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终可解析为 `doc`/`docx`/`file` 的 wiki URL。
- **Drive file 评论**:仅支持白名单扩展名的普通文件。当前支持:`.md``.txt``.json``.csv``.go``.js``.py``.pptx``.png``.jpg``.jpeg``.zip``.mp3``.mp4`
- **Drive file 暂不支持**`.pdf``.docx``.xlsx` 等未在白名单内的普通文件会被 CLI 拒绝,并提示“当前还不支持这种类型的评论”。这些类型虽然可能接受 OpenAPI 请求,但在页面评论展示上存在问题。
- **Drive file 只支持全文评论**file 目标不支持局部评论,不允许传 `--block-id``--selection-with-ellipsis`。由于当前 OpenAPI 要求 file 评论传入非空 `anchor.block_id`CLI 会固定传占位值 `test`UI 上仍表现为文件全文评论。
-`--block-id`shortcut 创建**局部评论(划词评论)**;该模式支持 `docx``sheet``slides`,以及最终可解析为这些类型的 wiki URL。
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`sheet 没有全文评论,`--full-comment` 不可用。
- **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "<SLIDE_BLOCK_TYPE>!<XML_ELEMENT_ID>"`。CLI 会将其拆分映射到 `anchor.block_id` / `anchor.slide_block_type`。此时 `--full-comment``--selection-with-ellipsis` 不可用。

View File

@@ -2,8 +2,8 @@
## Metrics
- Denominator: 29 leaf commands
- Covered: 8
- Coverage: 27.6%
- Covered: 9
- Coverage: 31.0%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -11,18 +11,20 @@
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File: dry-run coverage for `drive +add-comment` on supported Drive file targets; pins the `metas.batch_query -> files/:token/new_comments` request chain, `file_type=file`, and the required placeholder `anchor.block_id`.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
- Blocked area: live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
- Blocked area: live export, permission, subscription, reply, and file comment API flows still need deterministic remote fixtures and filesystem setup.
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` and `TestDriveUploadDryRun_WithFileToken` cover the wiki-target and overwrite request shapes for `drive +upload`; live upload/status/duplicate workflows also use real `+upload` against the backend.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| | drive +add-comment | shortcut | | none | no comment workflow yet |
| | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id` | dry-run coverage in place; opt-in live workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveAddCommentDryRun_File(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+add-comment",
"--doc", "https://example.larksuite.com/file/fileDryRunComment",
"--content", `[{"type":"text","text":"please update README"}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("api.0.url=%q, want metas/batch_query\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.request_docs.0.doc_type").String(); got != "file" {
t.Fatalf("api.0.body.request_docs.0.doc_type=%q, want file\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/fileDryRunComment/new_comments" {
t.Fatalf("api.1.url=%q, want new_comments\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.file_type").String(); got != "file" {
t.Fatalf("api.1.body.file_type=%q, want file\nstdout:\n%s", got, out)
}
if !gjson.Get(out, "api.1.body.anchor.block_id").Exists() {
t.Fatalf("api.1.body.anchor.block_id should exist for file comment\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.1.body.anchor.block_id").String(); got != "test" {
t.Fatalf("api.1.body.anchor.block_id=%q, want test\nstdout:\n%s", got, out)
}
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" {
t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the supported file comment workflow")
}
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-drive-comment-" + suffix + ".md"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", fileName,
"--content", "# Comment target\n\nbody\n",
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
DefaultAs: "bot",
})
clie2e.ReportCleanupFailure(parentT, "delete file comment target "+fileToken, deleteResult, deleteErr)
})
commentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+add-comment",
"--doc", fileToken,
"--type", "file",
"--content", `[{"type":"text","text":"please update README"}]`,
},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
commentResult.AssertExitCode(t, 0)
commentResult.AssertStdoutStatus(t, true)
commentID := gjson.Get(commentResult.Stdout, "data.comment_id").String()
require.NotEmpty(t, commentID, "stdout:\n%s", commentResult.Stdout)
if got := gjson.Get(commentResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("data.file_type=%q, want file\nstdout:\n%s", got, commentResult.Stdout)
}
if got := gjson.Get(commentResult.Stdout, "data.file_name").String(); got != fileName {
t.Fatalf("data.file_name=%q, want %q\nstdout:\n%s", got, fileName, commentResult.Stdout)
}
if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" {
t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout)
}
}