From a0586d7589a97b767020c4cf3b619042b98ec3fb Mon Sep 17 00:00:00 2001 From: zgz2048 Date: Fri, 22 May 2026 17:05:59 +0800 Subject: [PATCH 1/2] feat: support base record comments --- shortcuts/drive/drive_add_comment.go | 153 +++++++++++- shortcuts/drive/drive_add_comment_test.go | 231 +++++++++++++++++- skill-template/domains/drive.md | 9 +- skills/lark-drive/SKILL.md | 13 +- .../references/lark-drive-add-comment.md | 27 +- tests/cli_e2e/drive/coverage.md | 4 +- .../drive/drive_add_comment_dryrun_test.go | 37 +++ 7 files changed, 441 insertions(+), 33 deletions(-) diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go index b40ca959d..99126305a 100644 --- a/shortcuts/drive/drive_add_comment.go +++ b/shortcuts/drive/drive_add_comment.go @@ -121,7 +121,7 @@ const ( var DriveAddComment = common.Shortcut{ Service: "drive", Command: "+add-comment", - Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only", + Description: "Add a comment to doc/docx/file/sheet/slides/base(bitable); file targets support selected extensions and full comments only", Risk: "write", Scopes: []string{ "drive:drive.metadata:readonly", @@ -131,12 +131,12 @@ var DriveAddComment = common.Shortcut{ }, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {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: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base(bitable) URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true}, + {Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}}, {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')"}, - {Name: "block-id", Desc: "for docx: anchor block ID; for sheet: ! (e.g. a281f9!D6); for slides: ! (e.g. shape!bPq)"}, + {Name: "block-id", Desc: "for docx: anchor block ID; for sheet: !; for slides: !; for base(bitable): !!"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type")) @@ -148,6 +148,11 @@ var DriveAddComment = common.Shortcut{ return err } + if docRef.Kind == "base" { + _, err := parseBaseCommentAnchor(runtime) + return err + } + // Sheet comment validation. if docRef.Kind == "sheet" { blockID := strings.TrimSpace(runtime.Str("block-id")) @@ -215,6 +220,23 @@ var DriveAddComment = common.Shortcut{ resolvedToken = target.FileToken } + if resolvedKind == "base" { + anchor, err := parseBaseCommentAnchor(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + commentBody := buildBaseCommentCreateV2Request(replyElements, anchor) + desc := "1-step request: create base(bitable) record-local comment" + if isWiki { + desc = "2-step orchestration: resolve wiki -> create base(bitable) record-local comment" + } + return common.NewDryRunAPI(). + Desc(desc). + POST("/open-apis/drive/v1/files/:file_token/new_comments"). + Body(commentBody). + Set("file_token", resolvedToken) + } + // Sheet comment dry-run. if resolvedKind == "sheet" { anchor, _ := parseSheetCellRef(blockID) @@ -352,6 +374,14 @@ var DriveAddComment = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { // Sheet comment: direct URL or token fast path. docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type")) + if docRef.Kind == "base" { + return executeBaseComment(runtime, resolvedCommentTarget{ + DocID: docRef.Token, + FileToken: docRef.Token, + FileType: "base", + ResolvedBy: "base", + }) + } if docRef.Kind == "sheet" { return executeSheetComment(runtime, docRef) } @@ -375,6 +405,9 @@ var DriveAddComment = common.Shortcut{ if target.FileType == "slides" { return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken}) } + if target.FileType == "base" { + return executeBaseComment(runtime, target) + } if target.FileType == "file" { return executeFileComment(runtime, target) } @@ -482,6 +515,12 @@ 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, "/base/"); ok { + return commentDocRef{Kind: "base", Token: token}, nil + } + if token, ok := extractURLToken(raw, "/bitable/"); ok { + return commentDocRef{Kind: "base", Token: token}, nil + } if token, ok := extractURLToken(raw, "/file/"); ok { return commentDocRef{Kind: "file", Token: token}, nil } @@ -495,7 +534,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/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw) + return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides/base(bitable) URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw) } if strings.ContainsAny(raw, "/?#") { return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw) @@ -504,7 +543,10 @@ 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, file, sheet, slides)") + return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides, bitable, base; use bitable as the wire value, base is accepted as a compatibility alias)") + } + if docType == "bitable" || docType == "base" { + return commentDocRef{Kind: "base", Token: raw}, nil } return commentDocRef{Kind: docType, Token: raw}, nil } @@ -515,7 +557,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i return resolvedCommentTarget{}, err } - if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" { + if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" || docRef.Kind == "base" { if mode == commentModeLocal { switch docRef.Kind { case "doc": @@ -557,6 +599,16 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id !", objType) } + if objType == "bitable" { + fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to base: %s\n", common.MaskToken(objToken)) + return resolvedCommentTarget{ + DocID: objToken, + FileToken: objToken, + FileType: "base", + ResolvedBy: "wiki", + WikiToken: docRef.Token, + }, nil + } if objType == "sheet" { // Sheet comments are handled via the sheet fast path in Execute. fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) @@ -595,7 +647,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id !, for slides use --block-id !", objType) } if mode == commentModeFull && objType != "docx" && objType != "doc" { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType) + return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base", objType) } fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) @@ -791,6 +843,12 @@ type sheetAnchor struct { Row int } +type baseAnchor struct { + BlockID string + BaseRecordID string + BaseViewID string +} + func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} { body := map[string]interface{}{ "file_type": fileType, @@ -817,6 +875,18 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply return body } +func buildBaseCommentCreateV2Request(replyElements []map[string]interface{}, anchor baseAnchor) map[string]interface{} { + return map[string]interface{}{ + "file_type": "bitable", + "reply_elements": replyElements, + "anchor": map[string]interface{}{ + "block_id": anchor.BlockID, + "base_record_id": anchor.BaseRecordID, + "base_view_id": anchor.BaseViewID, + }, + } +} + func anchorBlockIDForDryRun(blockID string) string { if strings.TrimSpace(blockID) != "" { return strings.TrimSpace(blockID) @@ -824,6 +894,26 @@ func anchorBlockIDForDryRun(blockID string) string { return "" } +func parseBaseCommentAnchor(runtime *common.RuntimeContext) (baseAnchor, error) { + blockID := strings.TrimSpace(runtime.Str("block-id")) + if blockID == "" { + return baseAnchor{}, output.ErrValidation("--block-id is required for base(bitable) record-local comments (format: !!, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R)") + } + return parseBaseBlockRef(blockID) +} + +func parseBaseBlockRef(blockID string) (baseAnchor, error) { + parts := strings.Split(strings.TrimSpace(blockID), "!") + if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" { + return baseAnchor{}, output.ErrValidation("base(bitable) record-local comments require --block-id in !! format, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R") + } + return baseAnchor{ + BlockID: strings.TrimSpace(parts[0]), + BaseRecordID: strings.TrimSpace(parts[1]), + BaseViewID: strings.TrimSpace(parts[2]), + }, nil +} + func parseSlidesBlockRef(blockID string) (string, string, error) { blockID = strings.TrimSpace(blockID) if blockID == "" { @@ -1038,6 +1128,53 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e return nil } +func executeBaseComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error { + replyElements, err := parseCommentReplyElements(runtime.Str("content")) + if err != nil { + return err + } + anchor, err := parseBaseCommentAnchor(runtime) + if err != nil { + return err + } + + requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken)) + requestBody := buildBaseCommentCreateV2Request(replyElements, anchor) + + fmt.Fprintf(runtime.IO().ErrOut, "Creating base(bitable) record-local comment in %s (table=%s, record=%s, view=%s)\n", + common.MaskToken(target.FileToken), anchor.BlockID, anchor.BaseRecordID, anchor.BaseViewID) + + data, err := runtime.CallAPI("POST", requestPath, nil, requestBody) + if err != nil { + return err + } + + out := map[string]interface{}{ + "file_token": target.FileToken, + "file_type": "bitable", + "resolved_by": target.ResolvedBy, + "comment_mode": "base_record", + "base_block_id": anchor.BlockID, + "base_record_id": anchor.BaseRecordID, + "base_view_id": anchor.BaseViewID, + } + if commentID := data["comment_id"]; commentID != nil { + out["comment_id"] = commentID + } + if replyID := data["reply_id"]; replyID != nil { + out["reply_id"] = replyID + } + 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 executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error { replyElements, err := parseCommentReplyElements(runtime.Str("content")) if err != nil { diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go index 7875893eb..1525e91aa 100644 --- a/shortcuts/drive/drive_add_comment_test.go +++ b/shortcuts/drive/drive_add_comment_test.go @@ -112,6 +112,20 @@ func TestParseCommentDocRef(t *testing.T) { wantKind: "file", wantToken: "fileToken", }, + { + name: "raw token with type bitable", + input: "baseToken", + docType: "bitable", + wantKind: "base", + wantToken: "baseToken", + }, + { + name: "raw token with type base alias", + input: "baseToken", + docType: "base", + wantKind: "base", + wantToken: "baseToken", + }, { name: "raw token without type", input: "xxxxxx", @@ -135,6 +149,18 @@ func TestParseCommentDocRef(t *testing.T) { wantKind: "file", wantToken: "boxcn123", }, + { + name: "base url", + input: "https://example.larksuite.com/base/baseToken123?table=tbl1", + wantKind: "base", + wantToken: "baseToken123", + }, + { + name: "bitable url", + input: "https://example.larksuite.com/bitable/baseToken456?table=tbl1", + wantKind: "base", + wantToken: "baseToken456", + }, { name: "unsupported url", input: "https://example.com/not-a-doc", @@ -711,6 +737,35 @@ func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) { } } +func TestBuildBaseCommentCreateV2Request(t *testing.T) { + t.Parallel() + replyElements := []map[string]interface{}{ + {"type": "text", "text": "base comment"}, + } + got := buildBaseCommentCreateV2Request(replyElements, baseAnchor{ + BlockID: "tbl9mp6fj9kDKHQV", + BaseRecordID: "recBIBgGmb", + BaseViewID: "vewc46MG1R", + }) + + if got["file_type"] != "bitable" { + t.Fatalf("expected file_type bitable, got %#v", got["file_type"]) + } + anchor, ok := got["anchor"].(map[string]interface{}) + if !ok { + t.Fatalf("expected anchor map, got %#v", got["anchor"]) + } + if anchor["block_id"] != "tbl9mp6fj9kDKHQV" { + t.Fatalf("expected block_id tbl9mp6fj9kDKHQV, got %#v", anchor["block_id"]) + } + if anchor["base_record_id"] != "recBIBgGmb" { + t.Fatalf("expected base_record_id recBIBgGmb, got %#v", anchor["base_record_id"]) + } + if anchor["base_view_id"] != "vewc46MG1R" { + t.Fatalf("expected base_view_id vewc46MG1R, got %#v", anchor["base_view_id"]) + } +} + // ── Sheet cell ref parsing tests ──────────────────────────────────────────── func TestParseSheetCellRef(t *testing.T) { @@ -970,6 +1025,42 @@ func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) { } } +func TestBaseCommentValidateMissingBlockID(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/base/baseToken", + "--content", `[{"type":"text","text":"test"}]`, + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--block-id is required") { + t.Fatalf("expected block-id required error, got: %v", err) + } +} + +func TestBaseCommentValidateMalformedBlockID(t *testing.T) { + cases := []string{ + "tbl9mp6fj9kDKHQV", + "tbl9mp6fj9kDKHQV!recBIBgGmb", + "tbl9mp6fj9kDKHQV!!vewc46MG1R", + } + for _, blockID := range cases { + t.Run(blockID, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/base/baseToken", + "--content", `[{"type":"text","text":"test"}]`, + "--block-id", blockID, + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "!!") { + t.Fatalf("expected block-id format error, got: %v", err) + } + }) + } +} + // ── Slides comment execute tests ──────────────────────────────────────────── func TestSlidesCommentExecuteSuccess(t *testing.T) { @@ -1180,6 +1271,87 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) { } } +func TestBaseCommentExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + createStub := &httpmock.Stub{ + Method: "POST", URL: "/open-apis/drive/v1/files/baseToken/new_comments", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "comment_id": "baseComment123", + "reply_id": "baseReply123", + "created_at": 1700000000, + }, + }, + } + reg.Register(createStub) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/base/baseToken", + "--content", `[{"type":"text","text":"请看这条记录"}]`, + "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var requestBody map[string]interface{} + if err := json.Unmarshal(createStub.CapturedBody, &requestBody); err != nil { + t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(createStub.CapturedBody)) + } + if got := mustStringField(t, requestBody, "file_type", "request.file_type"); got != "bitable" { + t.Fatalf("request file_type = %q, want bitable", got) + } + anchor := mustMapValue(t, requestBody["anchor"], "request.anchor") + if got := mustStringField(t, anchor, "block_id", "request.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" { + t.Fatalf("request block_id = %q, want tbl9mp6fj9kDKHQV", got) + } + if got := mustStringField(t, anchor, "base_record_id", "request.anchor.base_record_id"); got != "recBIBgGmb" { + t.Fatalf("request base_record_id = %q, want recBIBgGmb", got) + } + if got := mustStringField(t, anchor, "base_view_id", "request.anchor.base_view_id"); got != "vewc46MG1R" { + t.Fatalf("request base_view_id = %q, want vewc46MG1R", got) + } + + out := decodeJSONMap(t, stdout.String()) + data := mustMapValue(t, out["data"], "data") + if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" { + t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, data, "comment_mode", "data.comment_mode"); got != "base_record" { + t.Fatalf("stdout comment_mode = %q, want base_record\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "baseReply123" { + t.Fatalf("stdout reply_id = %q, want baseReply123\nstdout:\n%s", got, stdout.String()) + } +} + +func TestBaseCommentExecuteBareToken(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/drive/v1/files/baseBareToken/new_comments", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{"comment_id": "baseBareComment"}, + }, + }) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "baseBareToken", + "--type", "bitable", + "--content", `[{"type":"text","text":"ok"}]`, + "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "baseBareComment") { + t.Fatalf("stdout missing comment_id: %s", stdout.String()) + } +} + func TestFileCommentExecuteSuccess(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ @@ -1418,6 +1590,40 @@ func TestDryRunSlidesDirectURL(t *testing.T) { } } +func TestDryRunBaseDirectURL(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/base/baseToken", + "--content", `[{"type":"text","text":"test"}]`, + "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "record-local comment") { + t.Fatalf("dry-run output missing record-local comment: %s", stdout.String()) + } + out := decodeJSONMap(t, stdout.String()) + api := mustSliceValue(t, out["api"], "api") + call := mustMapValue(t, api[0], "api[0]") + body := mustMapValue(t, call["body"], "api[0].body") + anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor") + if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "bitable" { + t.Fatalf("dry-run body.file_type = %q, want bitable\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, anchor, "block_id", "api[0].body.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" { + t.Fatalf("dry-run body.anchor.block_id = %q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, anchor, "base_record_id", "api[0].body.anchor.base_record_id"); got != "recBIBgGmb" { + t.Fatalf("dry-run body.anchor.base_record_id = %q, want recBIBgGmb\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, anchor, "base_view_id", "api[0].body.anchor.base_view_id"); got != "vewc46MG1R" { + t.Fatalf("dry-run body.anchor.base_view_id = %q, want vewc46MG1R\nstdout:\n%s", got, stdout.String()) + } +} + func TestDryRunWikiResolvesToSlides(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ @@ -1621,7 +1827,7 @@ func TestResolveWikiToDocxFullComment(t *testing.T) { } } -func TestResolveWikiToUnsupportedType(t *testing.T) { +func TestResolveWikiToBaseComment(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", @@ -1632,14 +1838,33 @@ func TestResolveWikiToUnsupportedType(t *testing.T) { }, }, }) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"}, + }, + }) err := mountAndRunDrive(t, DriveAddComment, []string{ "+add-comment", "--doc", "https://example.larksuite.com/wiki/wikiToken", "--content", `[{"type":"text","text":"test"}]`, + "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", "--as", "user", }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") { - t.Fatalf("expected unsupported type error, got: %v", err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "wikiBaseComment") { + 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 != "bitable" { + t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" { + t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String()) } } diff --git a/skill-template/domains/drive.md b/skill-template/domains/drive.md index 83d7ca008..9b7e90568 100644 --- a/skill-template/domains/drive.md +++ b/skill-template/domains/drive.md @@ -61,7 +61,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX' | `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` | | `doc` | 旧版云文档 | `drive file.comments.*` | | `sheet` | 电子表格 | `sheets.*` | - | `bitable` | 多维表格 | `bitable.*` | + | `bitable` | 多维表格 / Base | `drive file.comments.*`、`bitable.*` | | `slides` | 幻灯片 | `drive.*` | | `file` | 文件 | `drive.*` | | `mindnote` | 思维导图 | `drive.*` | @@ -124,8 +124,9 @@ Drive Folder (云空间文件夹) - 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL。 - 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。 - `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。 -- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。 -- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。 +- Base 记录局部评论使用 `--type bitable` / `--type base` 或 `/base/`、`/bitable/`、wiki Base 链接;裸 token 推荐传 `bitable`,`base` 仅作为兼容别名兜底。Base 不支持全局评论,所有评论都挂在记录上;定位信息必须是 file token(base token)+ `--block-id !!`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头。view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点,但必须传;ID 可通过 [`lark-base`](../../skills/lark-base/SKILL.md) 获取。 +- 如果 wiki 解析后不是 `doc`/`docx`/`bitable`,不要用 `+add-comment`。 +- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`;docx/sheet/slides 局部评论传 `anchor.block_id`,Base 记录局部评论传 `anchor.block_id`(table_id)、`anchor.base_record_id`、`anchor.base_view_id`。 ### 评论查询与统计口径(关键!) @@ -189,7 +190,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": " |----------|------|----------| | `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` | | `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 | -| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet) | +| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) | ### 授权当前应用访问文档 diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 6e62d1227..755f6d605 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -79,7 +79,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX' | `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` | | `doc` | 旧版云文档 | `drive file.comments.*` | | `sheet` | 电子表格 | `sheets.*` | - | `bitable` | 多维表格 | `bitable.*` | + | `bitable` | 多维表格 / Base | `drive file.comments.*`、`bitable.*` | | `slides` | 幻灯片 | `drive.*` | | `file` | 文件 | `drive.*` | | `mindnote` | 思维导图 | `drive.*` | @@ -130,7 +130,7 @@ 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` 使用 `!`,`slides` 使用 `!`,且都支持最终解析到对应类型的 wiki URL;Drive file 不支持局部评论 | +| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `!`,`slides` 使用 `!`;Base 只有记录局部评论,定位为 file_token(base_token) + `--block-id !!` | | 添加全文评论 | `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 | @@ -148,8 +148,11 @@ Drive Folder (云空间文件夹) - 评论写入内容(添加评论、回复评论、编辑回复)里的文本不能直接出现 `<`、`>`;提交前必须先转义:`<` -> `<`,`>` -> `>`。 - 使用 `drive +add-comment` 时,shortcut 会对 `type=text` 的文本元素自动做上述转义兜底;如果直接调用 `drive file.comments create_v2`、`drive file.comment.replys create`、`drive file.comment.replys update`,则需要在请求里自行传入已转义的内容。 -- 如果 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`。 +- Base 记录局部评论使用 `--type bitable` / `--type base` 或 `/base/`、`/bitable/`、wiki Base 链接;`bitable` 和 Base 是同一概念,`bitable` 是内部代号、Base 是产品名,裸 token 推荐传 `bitable`,`base` 仅作为兼容别名兜底。 +- Base 不支持全局评论,所有评论都挂在记录上;定位信息必须是 file token(base token)+ `--block-id !!`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头。view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点;只要在同一记录上都能看到评论,但必须传,否则通知无法确定跳转视图。ID 可通过 [`lark-base`](../lark-base/SKILL.md) 获取。 +- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`/`bitable`/`base`,不要用 `+add-comment`。 +- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`;docx/sheet/slides 局部评论传 `anchor.block_id`,Base 记录局部评论传 `anchor.block_id`(table_id)、`anchor.base_record_id`、`anchor.base_view_id`。 +- 直接调用原生 `drive.file.comments.*` / `drive.file.comment.replys.*` 评论 Base 文档时,`file_type` 填 `bitable`,不要填 `base`。 ### 评论查询与统计口径(关键!) @@ -218,7 +221,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": " |----------|------|----------| | `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` | | `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 | -| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides) | +| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) | #### `permission.public.patch` 错误码引导 diff --git a/skills/lark-drive/references/lark-drive-add-comment.md b/skills/lark-drive/references/lark-drive-add-comment.md index 35c181537..484b0fe36 100644 --- a/skills/lark-drive/references/lark-drive-add-comment.md +++ b/skills/lark-drive/references/lark-drive-add-comment.md @@ -3,7 +3,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -给文档、受支持的 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。 +给文档、受支持的 Drive 普通文件、电子表格、飞书幻灯片或 Base 添加评论。未指定位置时创建全文评论;指定 `--block-id` 时创建局部评论,不同类型的 `--block-id` 格式见下文。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、Drive file URL/token(**仅支持白名单扩展名,且只支持全文评论**)、sheet URL、slides URL、base(bitable) URL,也支持传最终可解析为 doc/docx/file/sheet/slides/base(bitable) 的 wiki URL。 ## 命令 @@ -127,6 +127,12 @@ lark-cli drive file.comments create_v2 \ --params '{"file_token":""}' \ --data '{"file_type":"docx","reply_elements":[{"type":"text","text":"全文评论内容"}]}' +# Base 记录局部评论;原生 file_type 传 bitable。 +lark-cli drive +add-comment \ + --doc "" --type bitable \ + --block-id "!!" \ + --content '[{"type":"text","text":"Base record-local comment"}]' + # 预览底层调用链 lark-cli drive +add-comment \ --doc "https://example.larksuite.com/docx/" \ @@ -139,11 +145,11 @@ lark-cli drive +add-comment \ | 参数 | 必填 | 说明 | |------|------|------| -| `--doc` | 是 | 文档 URL / token、file / sheet / slides URL,或可解析到 `doc`/`docx`/`file`/`sheet`/`slides` 的 wiki URL | -| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`file`、`sheet`、`slides`。URL 输入时自动识别,无需传 | +| `--doc` | 是 | 文档 URL / token、file / sheet / slides / base(bitable) URL,或可解析到 `doc`/`docx`/`file`/`sheet`/`slides`/`base(bitable)` 的 wiki URL | +| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`file`、`sheet`、`slides`、`bitable`、`base`;评论 Base 文档推荐传 `bitable`,`base` 仅作为兼容别名兜底。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 评论**:格式为 `!`(如 `a281f9!D6`) | +| `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --api-version v2 --detail with-ids` 获取;sheet 用 `!`,slides 用 `!`,Base 用 `!!` | ## 行为说明 @@ -152,10 +158,11 @@ lark-cli drive +add-comment \ - 未传 `--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 上仍表现为文件全文评论。 +- **Drive file 只支持全文评论**:file 目标不支持局部评论,不允许传 `--block-id` 或 `--selection-with-ellipsis`。 - 传 `--block-id` 时,shortcut 创建**局部评论(划词评论)**;该模式支持 `docx`、`sheet`、`slides`,以及最终可解析为这些类型的 wiki URL。 - **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "!"` 指定单元格(如 `a281f9!D6`);sheet 没有全文评论,`--full-comment` 不可用。 -- **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "!"`。CLI 会将其拆分映射到 `anchor.block_id` / `anchor.slide_block_type`。此时 `--full-comment` 和 `--selection-with-ellipsis` 不可用。 +- **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "!"`。此时 `--full-comment` 和 `--selection-with-ellipsis` 不可用。 +- **Base 记录局部评论**:Base 不支持全局评论,所有评论都挂在记录上;裸 token 可传 `--type bitable` 或 `--type base`,推荐 `bitable`。定位信息必须是 file token(base token)+ `--block-id "!!"`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头;view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点,但必须传。ID 获取参考 [`lark-base`](../../lark-base/SKILL.md)。 - **Slide 参数映射示例**:`--block-id` 由 PPT XML 元素类型和元素 `id` 组成。例如: - `` 对应 `--block-id slide!pkk`,表示给整页评论。 - `` 对应 `--block-id img!bPk`,表示给图片元素评论。 @@ -165,13 +172,11 @@ lark-cli drive +add-comment \ - `type=text` 的评论文本不能直接包含 `<`、`>`;应优先传 `<`、`>`。shortcut 在发送前也会自动将 `<`、`>` 转义为 `<`、`>` 作为兜底。 - **所有 `type=text` 元素的字符总和 ≤ 10000**(按字符算,中英文 / 符号一视同仁)。超过会被 shortcut 在发送前拒绝,并指出累计超长的元素。**拆成多个 text element 不能绕过这个上限**——上限是总额,不是每元素。需要更长内容就缩短或拆成多条评论。 - 长度限制只对 `type=text` 生效,`mention_user` / `link` 不计入。 -- 写入评论前会自动生成符合 OpenAPI 定义的请求体: - - 统一接口:`POST /new_comments` - - 统一字段:`file_type` + `reply_elements` - - 全文评论:省略 `anchor` - - 局部评论:传入 `anchor.block_id` +- 写入评论前会自动生成符合 OpenAPI 定义的请求体;shortcut 用户只需要传 `--doc`、`--content`,局部评论再传对应格式的 `--block-id`。 - `--dry-run` 仅预览调用链和请求体,不会实际写入。 - 如果需要更底层的控制,仍可改用 `lark-cli schema drive.file.comments.create_v2` + `lark-cli drive file.comments create_v2`。 +- 直接调用原生 `drive.file.comments.create_v2` 时,全文评论省略 `anchor`;docx/sheet/slides 局部评论传 `anchor.block_id`,Base 记录局部评论传 `anchor.block_id`(table_id)、`anchor.base_record_id`、`anchor.base_view_id`。 +- 直接调用原生 `drive.file.comments.*` / `drive.file.comment.replys.*` 评论 Base 文档时,`file_type` 填 `bitable`,不要填 `base`。 > [!CAUTION] > 这是**写入操作** —— 执行前必须确认用户意图。 diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 9cfc8f26c..8362fc812 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -11,7 +11,7 @@ - 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`. +- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields. - 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. @@ -24,7 +24,7 @@ | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | -| ✓ | 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 +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id !!` | dry-run coverage in place; opt-in live file 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 | diff --git a/tests/cli_e2e/drive/drive_add_comment_dryrun_test.go b/tests/cli_e2e/drive/drive_add_comment_dryrun_test.go index 1a65239fc..50635aa0c 100644 --- a/tests/cli_e2e/drive/drive_add_comment_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_add_comment_dryrun_test.go @@ -51,3 +51,40 @@ func TestDriveAddCommentDryRun_File(t *testing.T) { t.Fatalf("api.1.body.anchor.block_id=%q, want test\nstdout:\n%s", got, out) } } + +func TestDriveAddCommentDryRun_Base(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/base/baseDryRunComment", + "--content", `[{"type":"text","text":"please check this record"}]`, + "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", + "--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/files/baseDryRunComment/new_comments" { + t.Fatalf("api.0.url=%q, want new_comments\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.file_type").String(); got != "bitable" { + t.Fatalf("api.0.body.file_type=%q, want bitable\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.anchor.block_id").String(); got != "tbl9mp6fj9kDKHQV" { + t.Fatalf("api.0.body.anchor.block_id=%q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.anchor.base_record_id").String(); got != "recBIBgGmb" { + t.Fatalf("api.0.body.anchor.base_record_id=%q, want recBIBgGmb\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.anchor.base_view_id").String(); got != "vewc46MG1R" { + t.Fatalf("api.0.body.anchor.base_view_id=%q, want vewc46MG1R\nstdout:\n%s", got, out) + } +} From cc4983ab4266ad53173efd314da0aec0ca6f26f3 Mon Sep 17 00:00:00 2001 From: zgz2048 Date: Fri, 22 May 2026 17:42:13 +0800 Subject: [PATCH 2/2] fix: tighten base comment validation --- shortcuts/drive/drive_add_comment.go | 20 +-- shortcuts/drive/drive_add_comment_test.go | 116 ++++++++++++------ skill-template/domains/drive.md | 15 ++- .../references/lark-drive-add-comment.md | 14 ++- 4 files changed, 110 insertions(+), 55 deletions(-) diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go index 99126305a..b60f584b9 100644 --- a/shortcuts/drive/drive_add_comment.go +++ b/shortcuts/drive/drive_add_comment.go @@ -131,7 +131,7 @@ var DriveAddComment = common.Shortcut{ }, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base(bitable) URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true}, + {Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true}, {Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}}, {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"}, @@ -149,6 +149,12 @@ var DriveAddComment = common.Shortcut{ } if docRef.Kind == "base" { + if runtime.Bool("full-comment") { + return output.ErrValidation("--full-comment is not applicable for base(bitable) comments; use --block-id !!") + } + if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { + return output.ErrValidation("--selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id !!") + } _, err := parseBaseCommentAnchor(runtime) return err } @@ -193,7 +199,7 @@ var DriveAddComment = common.Shortcut{ 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") + return output.ErrValidation("local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments") } return nil @@ -534,7 +540,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/file/sheet/slides/base(bitable) URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw) + return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw) } if strings.ContainsAny(raw, "/?#") { return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw) @@ -561,7 +567,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i 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") + return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments") case "file": if err := validateFileCommentMode(mode, ""); err != nil { return resolvedCommentTarget{}, err @@ -599,7 +605,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id !", objType) } - if objType == "bitable" { + if objType == "bitable" || objType == "base" { fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to base: %s\n", common.MaskToken(objToken)) return resolvedCommentTarget{ DocID: objToken, @@ -644,10 +650,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i }, 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 !, for slides use --block-id !", objType) + return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, slides, and base(bitable); for sheet use --block-id !, for slides use --block-id !, for base use --block-id !!", objType) } if mode == commentModeFull && objType != "docx" && objType != "doc" { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base", objType) + return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base(bitable)", objType) } fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go index 1525e91aa..5482cdf4e 100644 --- a/shortcuts/drive/drive_add_comment_test.go +++ b/shortcuts/drive/drive_add_comment_test.go @@ -1061,6 +1061,42 @@ func TestBaseCommentValidateMalformedBlockID(t *testing.T) { } } +func TestBaseCommentValidateRejectsIncompatibleFlags(t *testing.T) { + cases := []struct { + name string + args []string + wantErr string + }{ + { + name: "full comment", + args: []string{"--full-comment"}, + wantErr: "--full-comment is not applicable for base(bitable) comments", + }, + { + name: "selection", + args: []string{"--selection-with-ellipsis", "some text"}, + wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + args := []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/base/baseToken", + "--content", `[{"type":"text","text":"test"}]`, + "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", + "--as", "user", + } + args = append(args, tc.args...) + err := mountAndRunDrive(t, DriveAddComment, args, f, stdout) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected %q error, got: %v", tc.wantErr, err) + } + }) + } +} + // ── Slides comment execute tests ──────────────────────────────────────────── func TestSlidesCommentExecuteSuccess(t *testing.T) { @@ -1828,43 +1864,47 @@ func TestResolveWikiToDocxFullComment(t *testing.T) { } func TestResolveWikiToBaseComment(t *testing.T) { - f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) - reg.Register(&httpmock.Stub{ - Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", - Body: map[string]interface{}{ - "code": 0, "msg": "success", - "data": map[string]interface{}{ - "node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"}, - }, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments", - Body: map[string]interface{}{ - "code": 0, "msg": "success", - "data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"}, - }, - }) - err := mountAndRunDrive(t, DriveAddComment, []string{ - "+add-comment", - "--doc", "https://example.larksuite.com/wiki/wikiToken", - "--content", `[{"type":"text","text":"test"}]`, - "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", - "--as", "user", - }, f, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(stdout.String(), "wikiBaseComment") { - 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 != "bitable" { - t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String()) - } - if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" { - t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String()) + for _, objType := range []string{"bitable", "base"} { + t.Run(objType, func(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "node": map[string]interface{}{"obj_type": objType, "obj_token": "bitToken"}, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"}, + }, + }) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/wiki/wikiToken", + "--content", `[{"type":"text","text":"test"}]`, + "--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "wikiBaseComment") { + 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 != "bitable" { + t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" { + t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String()) + } + }) } } @@ -1945,7 +1985,7 @@ func TestDocOldFormatLocalCommentRejected(t *testing.T) { "--block-id", "blk_123", "--as", "user", }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, and slides") { + if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, slides, and base(bitable)") { t.Fatalf("expected local comment rejection for old doc, got: %v", err) } } diff --git a/skill-template/domains/drive.md b/skill-template/domains/drive.md index 9b7e90568..fbaf4e8bd 100644 --- a/skill-template/domains/drive.md +++ b/skill-template/domains/drive.md @@ -112,8 +112,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`,以及最终解析为 `docx` 的 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` 使用 `!`,`slides` 使用 `!`;Base / bitable 只有记录局部评论,定位为 file_token(base token) + `--block-id !!` | +| 添加全文评论 | `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` | 同添加评论 | @@ -121,11 +121,14 @@ Drive Folder (云空间文件夹) ### 评论能力边界(关键!) - `drive +add-comment` 支持两种模式。 -- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL。 -- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。 +- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL。`sheet`、`slides`、Base / bitable 不支持全文评论。 +- 局部评论:传 `--block-id` 时启用;`docx` 支持文本定位或 block id,`sheet` 支持 `!`,`slides` 支持 `!`,Base / bitable 支持 `!!`;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 / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论;具体参数和定位方式见生成后的 `skills/lark-drive/references/lark-drive-add-comment.md`。 - `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。 -- Base 记录局部评论使用 `--type bitable` / `--type base` 或 `/base/`、`/bitable/`、wiki Base 链接;裸 token 推荐传 `bitable`,`base` 仅作为兼容别名兜底。Base 不支持全局评论,所有评论都挂在记录上;定位信息必须是 file token(base token)+ `--block-id !!`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头。view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点,但必须传;ID 可通过 [`lark-base`](../../skills/lark-base/SKILL.md) 获取。 -- 如果 wiki 解析后不是 `doc`/`docx`/`bitable`,不要用 `+add-comment`。 +- `slides` 评论要求显式传 `--block-id !`;CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。 +- Base 记录局部评论使用 `--type bitable` / `--type base` 或 `/base/`、`/bitable/`、wiki Base 链接;`bitable` 和 Base 是同一概念,`bitable` 是内部代号、Base 是产品名,裸 token 推荐传 `bitable`,`base` 仅作为兼容别名兜底。Base 不支持全局评论,所有评论都挂在记录上;定位信息必须是 file token(base token)+ `--block-id !!`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头。view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点,但必须传;ID 可通过 [`lark-base`](../../skills/lark-base/SKILL.md) 获取。 +- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`/`bitable`/`base`,不要用 `+add-comment`。 - 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`;docx/sheet/slides 局部评论传 `anchor.block_id`,Base 记录局部评论传 `anchor.block_id`(table_id)、`anchor.base_record_id`、`anchor.base_view_id`。 ### 评论查询与统计口径(关键!) diff --git a/skills/lark-drive/references/lark-drive-add-comment.md b/skills/lark-drive/references/lark-drive-add-comment.md index 484b0fe36..d7b543cee 100644 --- a/skills/lark-drive/references/lark-drive-add-comment.md +++ b/skills/lark-drive/references/lark-drive-add-comment.md @@ -3,7 +3,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -给文档、受支持的 Drive 普通文件、电子表格、飞书幻灯片或 Base 添加评论。未指定位置时创建全文评论;指定 `--block-id` 时创建局部评论,不同类型的 `--block-id` 格式见下文。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、Drive file URL/token(**仅支持白名单扩展名,且只支持全文评论**)、sheet URL、slides URL、base(bitable) URL,也支持传最终可解析为 doc/docx/file/sheet/slides/base(bitable) 的 wiki URL。 +给文档、受支持的 Drive 普通文件、电子表格、飞书幻灯片或 Base 添加评论。未指定位置时创建全文评论,但仅适用于 doc/docx、白名单 Drive file,以及解析为这些类型的 wiki;sheet、slides、Base(bitable) 必须指定 `--block-id`。不同类型的 `--block-id` 格式见下文。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、Drive file URL/token(**仅支持白名单扩展名,且只支持全文评论**)、sheet URL、slides URL、base/bitable URL,也支持传最终可解析为 doc/docx/file/sheet/slides/base(bitable) 的 wiki URL。 ## 命令 @@ -133,6 +133,12 @@ lark-cli drive +add-comment \ --block-id "!!" \ --content '[{"type":"text","text":"Base record-local comment"}]' +# `base` 也可作为裸 token 类型别名;/base/ 与 /bitable/ URL 都会自动识别为 Base。 +lark-cli drive +add-comment \ + --doc "" --type base \ + --block-id "!!" \ + --content '[{"type":"text","text":"Base alias comment"}]' + # 预览底层调用链 lark-cli drive +add-comment \ --doc "https://example.larksuite.com/docx/" \ @@ -145,10 +151,10 @@ lark-cli drive +add-comment \ | 参数 | 必填 | 说明 | |------|------|------| -| `--doc` | 是 | 文档 URL / token、file / sheet / slides / base(bitable) URL,或可解析到 `doc`/`docx`/`file`/`sheet`/`slides`/`base(bitable)` 的 wiki URL | +| `--doc` | 是 | 文档 URL / token、file / sheet / slides / base / bitable URL,或可解析到 `doc`/`docx`/`file`/`sheet`/`slides`/`base(bitable)` 的 wiki URL | | `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`file`、`sheet`、`slides`、`bitable`、`base`;评论 Base 文档推荐传 `bitable`,`base` 仅作为兼容别名兜底。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) | +| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论(仅适用于 doc/docx、白名单 Drive file,以及解析为这些类型的 wiki;不适用于 sheet、slides、Base / bitable) | | `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --api-version v2 --detail with-ids` 获取;sheet 用 `!`,slides 用 `!`,Base 用 `!!` | ## 行为说明 @@ -159,7 +165,7 @@ lark-cli drive +add-comment \ - **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`。 -- 传 `--block-id` 时,shortcut 创建**局部评论(划词评论)**;该模式支持 `docx`、`sheet`、`slides`,以及最终可解析为这些类型的 wiki URL。 +- 传 `--block-id` 时,shortcut 创建**局部评论(划词评论)**;该模式支持 `docx`、`sheet`、`slides`、Base / bitable,以及最终可解析为这些类型的 wiki URL。 - **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "!"` 指定单元格(如 `a281f9!D6`);sheet 没有全文评论,`--full-comment` 不可用。 - **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "!"`。此时 `--full-comment` 和 `--selection-with-ellipsis` 不可用。 - **Base 记录局部评论**:Base 不支持全局评论,所有评论都挂在记录上;裸 token 可传 `--type bitable` 或 `--type base`,推荐 `bitable`。定位信息必须是 file token(base token)+ `--block-id "!!"`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头;view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点,但必须传。ID 获取参考 [`lark-base`](../../lark-base/SKILL.md)。