Compare commits

..

1 Commits

Author SHA1 Message Date
wangweiming
35d130cd04 feat: add drive list comments shortcut 2026-07-10 11:56:20 +08:00
27 changed files with 1076 additions and 329 deletions

View File

@@ -2,23 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.68] - 2026-07-09
### Features
- **drive**: Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
- **slides**: add slides chart demo reference
### Bug Fixes
- register and consume --json shorthand for custom-format shortcuts (#1737)
- **drive**: abort push on parent sibling limit (#1813)
### Documentation
- require native charts in slide planning
- register knowledge organize workflow (#1828)
## [v1.0.67] - 2026-07-08
### Features
@@ -1438,7 +1421,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.68",
"version": "1.0.67",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -151,12 +151,12 @@ func resolveFetchLang(runtime *common.RuntimeContext) string {
// buildReadOption 拼装 read_option JSONfull/空模式返回 nil让服务端走默认全文路径。
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
mode := effectiveFetchReadMode(runtime)
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
ro := map[string]interface{}{"read_mode": mode}
if v := effectiveFetchStartBlockID(runtime, mode); v != "" {
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
ro["start_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
@@ -177,72 +177,6 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
return ro
}
func effectiveFetchReadMode(runtime *common.RuntimeContext) string {
mode := rawFetchReadMode(runtime)
if shouldUseDocSelectionAnchor(runtime, mode) {
if anchor := docSelectionAnchorStartBlockID(runtime); anchor != "" {
return "range"
}
}
return mode
}
func rawFetchReadMode(runtime *common.RuntimeContext) string {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" {
return "full"
}
return mode
}
func effectiveFetchStartBlockID(runtime *common.RuntimeContext, mode string) string {
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
return v
}
if mode == "range" && shouldUseDocSelectionAnchor(runtime, rawFetchReadMode(runtime)) {
if anchor := docSelectionAnchorStartBlockID(runtime); anchor != "" {
return anchor
}
}
return ""
}
func shouldUseDocSelectionAnchor(runtime *common.RuntimeContext, mode string) bool {
if runtime.Changed("start-block-id") || runtime.Changed("end-block-id") {
return false
}
if runtime.Changed("scope") {
return mode == "range"
}
return mode == "" || mode == "full"
}
func docSelectionAnchorStartBlockID(runtime *common.RuntimeContext) string {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return ""
}
anchor, ok := parseDocShareSelectionAnchor(ref.Fragment)
if !ok {
return ""
}
return anchor
}
func parseDocShareSelectionAnchor(raw string) (string, bool) {
value := strings.TrimSpace(raw)
value = strings.TrimPrefix(value, "#")
const prefix = "share-"
if !strings.HasPrefix(value, prefix) {
return "", false
}
anchorID := strings.TrimSpace(strings.TrimPrefix(value, prefix))
if anchorID == "" {
return "", false
}
return prefix + anchorID, true
}
// effectiveFetchDetail degrades detail options that cannot be represented by
// non-XML exports. The original flag value is left intact so callers can still
// surface an explicit warning in execute output.
@@ -274,7 +208,7 @@ func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[str
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
func validateReadModeFlags(runtime *common.RuntimeContext) error {
mode := effectiveFetchReadMode(runtime)
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
@@ -293,7 +227,7 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
case "outline":
return nil
case "range":
if effectiveFetchStartBlockID(runtime, mode) == "" &&
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id").WithParams(
errs.InvalidParam{Name: "--start-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"},

View File

@@ -180,64 +180,6 @@ func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
}
}
func TestBuildFetchBodyUsesSelectionAnchorFragmentAsRangeStart(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse")
body := buildFetchBody(runtime)
want := map[string]interface{}{
"read_mode": "range",
"start_block_id": "share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
}
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
t.Fatalf("read_option = %#v, want %#v", got, want)
}
}
func TestBuildFetchBodyExplicitFullIgnoresSelectionAnchorFragment(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse")
mustSetFetchFlag(t, runtime, "scope", "full")
body := buildFetchBody(runtime)
if _, ok := body["read_option"]; ok {
t.Fatalf("did not expect read_option for explicit full scope: %#v", body["read_option"])
}
}
func TestBuildFetchBodyDoesNotAutoReadOrdinaryFragment(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#blk_plain")
body := buildFetchBody(runtime)
if _, ok := body["read_option"]; ok {
t.Fatalf("did not expect read_option for ordinary URL fragment: %#v", body["read_option"])
}
}
func TestBuildFetchBodyDoesNotAutoReadUnsupportedSelectionAnchorFragments(t *testing.T) {
t.Parallel()
for _, doc := range []string{
"https://example.larksuite.com/wiki/wikcnToken#part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"https://example.larksuite.com/wiki/wikcnToken#share-",
} {
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "doc", doc)
body := buildFetchBody(runtime)
if _, ok := body["read_option"]; ok {
t.Fatalf("did not expect read_option for unsupported URL fragment %q: %#v", doc, body["read_option"])
}
}
}
func TestBuildReadOptionModes(t *testing.T) {
t.Parallel()
@@ -433,12 +375,6 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
"end-block-id": "blk_end",
},
},
{
name: "default scope with selection anchor fragment",
setFlags: map[string]string{
"doc": "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
},
{
name: "keyword with keyword",
setFlags: map[string]string{
@@ -948,7 +884,6 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
cmd.Flags().String("detail", fetchDefault("detail"), "")
cmd.Flags().String("lang", fetchDefault("lang"), "")

View File

@@ -17,9 +17,8 @@ import (
const docsSceneContextKey = "lark_cli_docs_scene"
type documentRef struct {
Kind string
Token string
Fragment string
Kind string
Token string
}
func parseDocumentRef(input string) (documentRef, error) {
@@ -29,13 +28,13 @@ func parseDocumentRef(input string) (documentRef, error) {
}
if token, ok := extractDocumentToken(raw, "/wiki/"); ok {
return documentRef{Kind: "wiki", Token: token, Fragment: extractDocumentFragment(raw)}, nil
return documentRef{Kind: "wiki", Token: token}, nil
}
if token, ok := extractDocumentToken(raw, "/docx/"); ok {
return documentRef{Kind: "docx", Token: token, Fragment: extractDocumentFragment(raw)}, nil
return documentRef{Kind: "docx", Token: token}, nil
}
if token, ok := extractDocumentToken(raw, "/doc/"); ok {
return documentRef{Kind: "doc", Token: token, Fragment: extractDocumentFragment(raw)}, nil
return documentRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc")
@@ -63,14 +62,6 @@ func extractDocumentToken(raw, marker string) (string, bool) {
return token, true
}
func extractDocumentFragment(raw string) string {
idx := strings.Index(raw, "#")
if idx < 0 {
return ""
}
return strings.TrimSpace(raw[idx+1:])
}
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id

View File

@@ -13,12 +13,11 @@ func TestParseDocumentRef(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantKind string
wantToken string
wantFragment string
wantErr string
name string
input string
wantKind string
wantToken string
wantErr string
}{
{
name: "docx url",
@@ -32,13 +31,6 @@ func TestParseDocumentRef(t *testing.T) {
wantKind: "wiki",
wantToken: "xxxxxx",
},
{
name: "wiki url with selection anchor",
input: "https://example.larksuite.com/wiki/xxxxxx#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
wantKind: "wiki",
wantToken: "xxxxxx",
wantFragment: "share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
{
name: "doc url",
input: "https://example.larksuite.com/doc/xxxxxx",
@@ -81,9 +73,6 @@ func TestParseDocumentRef(t *testing.T) {
if got.Token != tt.wantToken {
t.Fatalf("parseDocumentRef(%q) token = %q, want %q", tt.input, got.Token, tt.wantToken)
}
if got.Fragment != tt.wantFragment {
t.Fatalf("parseDocumentRef(%q) fragment = %q, want %q", tt.input, got.Fragment, tt.wantFragment)
}
})
}
}

View File

@@ -0,0 +1,336 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveListCommentsDefaultPageSize = 50
driveListCommentsDefaultSolvedStatus = "false"
driveListCommentsDefaultScope = "all"
driveListCommentsDefaultUserIDType = "open_id"
)
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "wiki"}
type driveListCommentsRef struct {
Token string
Type string
SourceFlag string
}
type driveListCommentsTarget struct {
FileToken string
FileType string
}
type driveListCommentsSpec struct {
Ref driveListCommentsRef
PageSize int
PageToken string
SolvedStatus string
CommentScope string
NeedReaction bool
NeedRelation bool
UserIDType string
}
// DriveListComments lists document comments through the Drive comments API,
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
var DriveListComments = common.Shortcut{
Service: "drive",
Command: "+list-comments",
Description: "List comments for doc/docx/sheet/file/slides/base(bitable), with URL parsing and Wiki token unwrapping",
Risk: "read",
Scopes: []string{"docs:document.comment:read"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/wiki); Wiki URLs are unwrapped automatically"},
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveListCommentsTypes},
{Name: "solved-status", Default: driveListCommentsDefaultSolvedStatus, Desc: "comment solved filter: false=unresolved, true=solved, all=all comments", Enum: []string{"false", "true", "all"}},
{Name: "comment-scope", Default: driveListCommentsDefaultScope, Desc: "comment scope filter: all=all comments, whole=full-document comments, partial=local/selection comments", Enum: []string{"all", "whole", "partial"}},
{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
{Name: "page-token", Desc: "pagination token from previous response"},
{Name: "user-id-type", Default: driveListCommentsDefaultUserIDType, Desc: "user ID type in the response", Enum: []string{"user_id", "union_id", "open_id"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return err
}
return validateDriveListCommentsSpec(spec)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateDriveListCommentsSpec(spec); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveListCommentsDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return err
}
if err := validateDriveListCommentsSpec(spec); err != nil {
return err
}
target, err := resolveDriveListCommentsTarget(ctx, runtime, spec.Ref)
if err != nil {
return err
}
params := buildDriveListCommentsParams(spec, target.FileType)
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments", validate.EncodePathSegment(target.FileToken))
data, err := runtime.CallAPITyped("GET", path, params, nil)
if err != nil {
return err
}
runtime.Out(buildDriveListCommentsOutput(target, data), nil)
return nil
},
}
func readDriveListCommentsSpec(runtime *common.RuntimeContext) (driveListCommentsSpec, error) {
ref, err := resolveDriveListCommentsInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveListCommentsSpec{}, err
}
return driveListCommentsSpec{
Ref: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
SolvedStatus: strings.TrimSpace(runtime.Str("solved-status")),
CommentScope: strings.TrimSpace(runtime.Str("comment-scope")),
NeedReaction: runtime.Bool("need-reaction"),
NeedRelation: runtime.Bool("need-relation"),
UserIDType: strings.TrimSpace(runtime.Str("user-id-type")),
}, nil
}
func validateDriveListCommentsSpec(spec driveListCommentsSpec) error {
if spec.PageSize < 1 || spec.PageSize > 100 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
}
if _, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --solved-status %q; allowed: false, true, all", spec.SolvedStatus).WithParam("--solved-status")
}
if _, ok := driveListCommentsScopeParam(spec.CommentScope); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --comment-scope %q; allowed: all, whole, partial", spec.CommentScope).WithParam("--comment-scope")
}
if spec.UserIDType != "user_id" && spec.UserIDType != "union_id" && spec.UserIDType != "open_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --user-id-type %q; allowed: user_id, union_id, open_id", spec.UserIDType).WithParam("--user-id-type")
}
return nil
}
func resolveDriveListCommentsInput(urlInput, tokenInput, explicitType string) (driveListCommentsRef, error) {
urlInput = strings.TrimSpace(urlInput)
tokenInput = strings.TrimSpace(tokenInput)
if urlInput != "" && tokenInput != "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
}
if urlInput == "" && tokenInput == "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
}
raw := urlInput
sourceFlag := "--url"
if raw == "" {
raw = tokenInput
sourceFlag = "--token"
}
inputType := normalizeDriveListCommentsType(strings.ToLower(strings.TrimSpace(explicitType)))
if ref, ok := common.ParseResourceURL(raw); ok {
refType := normalizeDriveListCommentsType(ref.Type)
if inputType != "" && inputType != refType {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
if !driveListCommentsTypeSupported(refType) {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, and wiki",
sourceFlag,
refType,
).WithParam(sourceFlag)
}
return driveListCommentsRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
}
if strings.Contains(raw, "://") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
}
if strings.ContainsAny(raw, "/?#") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
}
if inputType == "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, wiki)", sourceFlag).WithParam("--type")
}
if !driveListCommentsTypeSupported(inputType) {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, wiki", inputType).WithParam("--type")
}
return driveListCommentsRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
}
func normalizeDriveListCommentsType(docType string) string {
switch strings.TrimSpace(docType) {
case "base":
return "bitable"
default:
return strings.TrimSpace(docType)
}
}
func driveListCommentsTypeSupported(docType string) bool {
switch normalizeDriveListCommentsType(docType) {
case "doc", "docx", "sheet", "file", "slides", "bitable", "wiki":
return true
default:
return false
}
}
func resolveDriveListCommentsTarget(ctx context.Context, runtime *common.RuntimeContext, ref driveListCommentsRef) (driveListCommentsTarget, error) {
if ref.Type != "wiki" {
return driveListCommentsTarget{FileToken: ref.Token, FileType: ref.Type}, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
nil,
)
if err != nil {
return driveListCommentsTarget{}, err
}
node := common.GetMap(data, "node")
objType := normalizeDriveListCommentsType(common.GetString(node, "obj_type"))
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return driveListCommentsTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if !driveListCommentsTypeSupported(objType) || objType == "wiki" {
return driveListCommentsTarget{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, and bitable",
objType,
).WithParam(ref.SourceFlag)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return driveListCommentsTarget{FileToken: objToken, FileType: objType}, nil
}
func buildDriveListCommentsDryRun(spec driveListCommentsSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
params := buildDriveListCommentsParams(spec, "<obj_type from step 1>")
if spec.NeedRelation {
params["need_relation"] = "<sent only when obj_type is docx>"
}
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> list comments").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments").
Desc("[2] List comments on resolved document").
Params(params)
}
return common.NewDryRunAPI().
Desc("1-step request: list comments").
GET("/open-apis/drive/v1/files/:file_token/comments").
Params(buildDriveListCommentsParams(spec, spec.Ref.Type)).
Set("file_token", spec.Ref.Token)
}
func buildDriveListCommentsParams(spec driveListCommentsSpec, fileType string) map[string]interface{} {
params := map[string]interface{}{
"file_type": fileType,
"page_size": spec.PageSize,
"user_id_type": spec.UserIDType,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
if value, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); ok && value != nil {
params["is_solved"] = *value
}
if value, ok := driveListCommentsScopeParam(spec.CommentScope); ok && value != nil {
params["is_whole"] = *value
}
if spec.NeedReaction {
params["need_reaction"] = true
}
if spec.NeedRelation && fileType == "docx" {
params["need_relation"] = true
}
return params
}
func driveListCommentsSolvedStatusParam(status string) (*bool, bool) {
switch strings.TrimSpace(status) {
case "false", "":
value := false
return &value, true
case "true":
value := true
return &value, true
case "all":
return nil, true
default:
return nil, false
}
}
func driveListCommentsScopeParam(scope string) (*bool, bool) {
switch strings.TrimSpace(scope) {
case "all", "":
return nil, true
case "whole":
value := true
return &value, true
case "partial":
value := false
return &value, true
default:
return nil, false
}
}
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
items := common.GetSlice(data, "items")
return map[string]interface{}{
"file_token": target.FileToken,
"file_type": target.FileType,
"items": items,
"has_more": common.GetBool(data, "has_more"),
"page_token": common.GetString(data, "page_token"),
"count": len(items),
}
}

View File

@@ -0,0 +1,365 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestResolveDriveListCommentsInput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlInput string
rawInput string
docType string
wantResource string
wantType string
wantErr string
wantParam string
}{
{
name: "url docx",
urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki",
wantResource: "docxResource",
wantType: "docx",
},
{
name: "token flag also accepts url",
rawInput: "https://example.larksuite.com/base/bitableResource",
wantResource: "bitableResource",
wantType: "bitable",
},
{
name: "bare wiki token",
rawInput: "wikiResource",
docType: "wiki",
wantResource: "wikiResource",
wantType: "wiki",
},
{
name: "url and token mutually exclusive",
urlInput: "https://example.larksuite.com/docx/docxResource",
rawInput: "docxResource",
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "bare token needs type",
rawInput: "docxResource",
wantErr: "--type is required",
wantParam: "--type",
},
{
name: "type conflicts with url",
urlInput: "https://example.larksuite.com/wiki/wikiResource",
docType: "docx",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "unsupported url type",
urlInput: "https://example.larksuite.com/drive/folder/folderResource",
wantErr: "unsupported",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveDriveListCommentsInput(tt.urlInput, tt.rawInput, tt.docType)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveListCommentsValidationError(t, err, tt.wantParam)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Token != tt.wantResource || got.Type != tt.wantType {
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantResource, tt.wantType)
}
})
}
}
func TestValidateDriveListCommentsSpec(t *testing.T) {
t.Parallel()
valid := driveListCommentsSpec{
PageSize: 50,
SolvedStatus: "false",
CommentScope: "all",
UserIDType: "open_id",
}
tests := []struct {
name string
mutate func(*driveListCommentsSpec)
wantParam string
}{
{
name: "invalid page size",
mutate: func(spec *driveListCommentsSpec) {
spec.PageSize = 0
},
wantParam: "--page-size",
},
{
name: "invalid solved status",
mutate: func(spec *driveListCommentsSpec) {
spec.SolvedStatus = "open"
},
wantParam: "--solved-status",
},
{
name: "invalid comment scope",
mutate: func(spec *driveListCommentsSpec) {
spec.CommentScope = "inline"
},
wantParam: "--comment-scope",
},
{
name: "invalid user id type",
mutate: func(spec *driveListCommentsSpec) {
spec.UserIDType = "email"
},
wantParam: "--user-id-type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
spec := valid
tt.mutate(&spec)
err := validateDriveListCommentsSpec(spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
assertDriveListCommentsValidationError(t, err, tt.wantParam)
})
}
}
func assertDriveListCommentsValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
}
if cause := errors.Unwrap(err); cause != nil {
t.Fatalf("unexpected cause on direct validation error: %v", cause)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestBuildDriveListCommentsParams(t *testing.T) {
t.Parallel()
defaultSpec := driveListCommentsSpec{
PageSize: 50,
SolvedStatus: "false",
CommentScope: "all",
UserIDType: "open_id",
}
defaultParams := buildDriveListCommentsParams(defaultSpec, "docx")
if got := defaultParams["is_solved"]; got != false {
t.Fatalf("default is_solved = %#v, want false", got)
}
if _, ok := defaultParams["is_whole"]; ok {
t.Fatalf("default params should omit is_whole: %#v", defaultParams)
}
if got := defaultParams["user_id_type"]; got != "open_id" {
t.Fatalf("user_id_type = %#v, want open_id", got)
}
allPartialSpec := driveListCommentsSpec{
PageSize: 100,
PageToken: "next",
SolvedStatus: "all",
CommentScope: "partial",
NeedReaction: true,
NeedRelation: true,
UserIDType: "union_id",
}
allPartialParams := buildDriveListCommentsParams(allPartialSpec, "docx")
if _, ok := allPartialParams["is_solved"]; ok {
t.Fatalf("solved-status all should omit is_solved: %#v", allPartialParams)
}
if got := allPartialParams["is_whole"]; got != false {
t.Fatalf("comment-scope partial is_whole = %#v, want false", got)
}
if got := allPartialParams["need_reaction"]; got != true {
t.Fatalf("need_reaction = %#v, want true", got)
}
if got := allPartialParams["need_relation"]; got != true {
t.Fatalf("need_relation = %#v, want true for docx", got)
}
if got := allPartialParams["page_token"]; got != "next" {
t.Fatalf("page_token = %#v, want next", got)
}
sheetParams := buildDriveListCommentsParams(allPartialSpec, "sheet")
if _, ok := sheetParams["need_relation"]; ok {
t.Fatalf("need_relation should be ignored for non-docx: %#v", sheetParams)
}
}
func TestDriveListCommentsExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
if got := query.Get("is_solved"); got != "false" {
t.Errorf("is_solved = %q, want false", got)
}
if got := query.Get("is_whole"); got != "" {
t.Errorf("is_whole = %q, want omitted", got)
}
if got := query.Get("user_id_type"); got != "open_id" {
t.Errorf("user_id_type = %q, want open_id", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{
{"comment_id": "comment_1", "is_solved": false},
},
"has_more": true,
"page_token": "next",
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" {
t.Fatalf("file_token = %q, want docxResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
if got := data["count"]; got != float64(1) {
t.Fatalf("count = %#v, want 1", got)
}
}
func TestDriveListCommentsExecuteWikiResolvesToDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("token"); got != "wikiResource" {
t.Errorf("wiki token = %q, want wikiResource", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxFromWikiResource",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxFromWikiResource/comments",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("is_solved"); got != "" {
t.Errorf("is_solved = %q, want omitted for solved-status all", got)
}
if got := query.Get("is_whole"); got != "true" {
t.Errorf("is_whole = %q, want true", got)
}
if got := query.Get("need_relation"); got != "true" {
t.Errorf("need_relation = %q, want true for resolved docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--token", "wikiResource",
"--type", "wiki",
"--solved-status", "all",
"--comment-scope", "whole",
"--need-relation",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWikiResource" {
t.Fatalf("file_token = %q, want docxFromWikiResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
}

View File

@@ -15,6 +15,7 @@ func Shortcuts() []common.Shortcut {
DrivePreview,
DriveCover,
DriveAddComment,
DriveListComments,
DriveExport,
DriveExportDownload,
DriveImport,

View File

@@ -20,14 +20,15 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+download",
"+preview",
"+cover",
"+add-comment",
"+list-comments",
"+export",
"+export-download",
"+import",
"+version-history",
"+version-get",
"+version-revert",
"+version-delete",
"+add-comment",
"+export",
"+export-download",
"+import",
"+move",
"+delete",
"+status",

View File

@@ -21,15 +21,11 @@ func patchTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "dummy",
AppSecret: patchTestValue(),
AppSecret: "dummy",
Brand: core.BrandFeishu,
}
}
func patchTestValue() string {
return strings.Join([]string{"test", "secret"}, "-")
}
func runPatchShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}

View File

@@ -14,7 +14,7 @@ metadata:
```bash
# 常用示例
lark-cli docs +fetch --doc "文档URL或token;若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取"
lark-cli docs +fetch --doc "文档URL或token"
lark-cli docs +create --content '<title>标题</title><p>内容</p>'
lark-cli docs +update --doc "文档URL或token" --command append --content '<p>内容</p>'
```

View File

@@ -17,10 +17,8 @@ lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids
lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3
# 按 block id 区间精读
lark-cli docs +fetch --doc Z1Fj...tnAc --scope range --start-block-id blkA --end-block-id blkB --detail with-ids
# URL 带 #share 选区锚点时自动局部读取
lark-cli docs +fetch --doc 'docURL#share-anchor'
lark-cli docs +fetch --doc Z1Fj...tnAc \
--scope range --start-block-id blkA --end-block-id blkB --detail with-ids
# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
lark-cli docs +fetch --doc Z1Fj...tnAc \

View File

@@ -9,20 +9,6 @@
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
## 获取 `mindnote_id`
`--mindnote-id`**Mindnote 文档 token**,不是节点 ID。`lark-cli mindnotes` 只负责读取和写入思维笔记内部节点。
```bash
# 用户给了 Mindnote URL或给了可能包着 Mindnote 的 Wiki URL
lark-cli drive +inspect --url "<mindnote_or_wiki_url>"
```
处理规则:
- 普通 Mindnote URL`drive +inspect` 返回的 Mindnote token 可作为 `--mindnote-id`
- Wiki URL不要把 `/wiki/` 路径里的 wiki token 当作 `--mindnote-id`;必须先 `drive +inspect` 解包,确认底层类型是 `mindnote` 后再使用返回的真实 token。直接把 wiki token 传给 `mindnotes nodes list` 通常会返回 `3410003 resource not found`
## 命令
```bash
@@ -110,8 +96,8 @@ lark-cli mindnotes nodes create \
1. 先判断用户目标是不是“新建一个思维笔记”。
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
3. 如果是操作已有思维笔记,先按上方「获取 `mindnote_id`」确认已拿到 Mindnote 文档 token。
4. 确认目标类型**Mindnote**,把真实 Mindnote token 作为 `--mindnote-id`
3. 如果是操作已有思维笔记,先通过 token 类别判断
4. 确认是 **Mindnote**再拿到 `mindnote_id`
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`
7. 再执行 `mindnotes nodes create`
@@ -124,5 +110,4 @@ lark-cli mindnotes nodes create \
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
- [lark-drive](../../lark-drive/SKILL.md) — 解析 Mindnote / Wiki 等云空间资源
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,34 +0,0 @@
# XML 扩展块补充说明
本文件用于补充说明 block XML 扩展能力。常用标签和通用规则见 [`lark-doc-xml.md`](lark-doc-xml.md);后续新增其他 block 说明时可继续追加到本文件。
## OKR block
OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../lark-okr/SKILL.md) 确认可用周期;创建时只写 root-only `<okr cycle-id="..."/>` 挂载已有 OKR不构造 Objective/KR/Progress 子树。
获取时XML 结构示例如下:
```xml
<okr cycle-id="" cycle-name="CYCLE_NAME" user-name="USER_NAME">
<okr-objective objective-id="OBJECTIVE_ID" status="normal" percent="80" score="75">
<p>O 描述</p>
<okr-progress>
<p>O 进展</p>
<checkbox done="true">事项</checkbox>
</okr-progress>
<okr-key-result key-result-id="KEY_RESULT_ID" status="risk" percent="60" score="80">
<p>KR 描述</p>
<okr-progress>
<p>KR 进展</p>
</okr-progress>
</okr-key-result>
</okr-objective>
</okr>
```
- `cycle-id` 仅用于创建时挂载已有当前周期 OKR`cycle-name``user-name` 只读。
- `objective-id``key-result-id` 为只读业务 ID更新已有 OKR 时保持不变。
- `okr-objective` / `okr-key-result`
- 可更新 `status``percent``score``percent` / `score` 取值 0-100`status` 取值 `unset`/`normal`/`risk`/`extended`
- 不可更新 objective 和 key-result 内容描述。
- `okr-progress` 承载进展内容,支持更新。支持内嵌 `<p>``<checkbox>``<grid>``<img>`

View File

@@ -46,8 +46,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
- `<sub-page-list>``<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
- bitable、base_ref、synced_reference、synced_source — 不可创建,仅支持移动
- `<okr>` — 创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR完整结构与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md#okr-block)
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
# 四、块级复制与移动

View File

@@ -26,7 +26,8 @@ metadata:
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `drive +list-comments --need-relation` 返回评论位置,其他类型会静默忽略该参数;具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
@@ -80,13 +81,14 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
| 添加全文评论 | `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` | 同添加评论 |
| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url '<url>'`wiki URL/token 会自动解析到底层真实 token/type |
### 评论能力入口
- 添加评论优先使用 [`+add-comment`](references/lark-drive-add-comment.md)review / 审阅 / 校对场景默认尽量创建局部评论,不要把多个可定位问题合并为一条全文评论。
- 获取评论列表优先使用 [`+list-comments`](references/lark-drive-list-comments.md):推荐传 `--url`,支持 wiki 自动解包;参数细节见 reference。
- 评论查询、统计、排序、回复限制,先读 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md);其他文档类型暂不支持返回定位字段
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`;其他文档类型会静默忽略该参数
- reaction / 表情相关操作先读 [`lark-drive-reactions.md`](references/lark-drive-reactions.md);只有用户明确需要 reaction 信息时才带 `need_reaction=true`
- `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`
@@ -139,6 +141,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加评论,也支持解析到这些类型的 wiki URL评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable) 评论列表;优先传 URL支持 wiki 自动解包。 |
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |

View File

@@ -1,15 +1,27 @@
# 文档评论定位字段
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,docx 文档的评论查询必须带 `need_relation=true`
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,优先使用 `drive +list-comments --need-relation` 查询 docx 评论位置
## 适用范围
- 当前只有 `file_type=docx` 支持通过 `need_relation=true` 查询评论的位置,并返回可用于定位正文 block 的 `relation``parent_type``parent_token` 等字段。
- 其他文件类型暂不支持通过 `need_relation` 查询评论位置。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
- `drive +list-comments` 会在目标不是 docx 时静默忽略 `--need-relation`,避免把无效参数传给 OpenAPI。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
## 调用方式
分页列出评论时,`need_relation` 放在 query params
分页列出评论时,优先传 URLWiki URL / Wiki token 会自动解析到底层真实 token/type
```bash
lark-cli drive +list-comments --url '<docx_or_wiki_url>' --need-relation
```
如果只有 Wiki token显式传 `--type wiki`
```bash
lark-cli drive +list-comments --token '<wiki_token>' --type wiki --need-relation
```
只有在需要未被 shortcut 暴露的底层参数时,才直接调用 raw OpenAPI。此时把 `need_relation` 放在 query params
```bash
lark-cli drive file.comments list \
@@ -126,7 +138,7 @@ lark-cli docs +fetch --doc '<doc_token_or_url>' --detail with-ids
## 定位流程
1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。
2.`drive file.comments list` `drive file.comments batch_query` 获取评论,并带 `need_relation=true`
2.`drive +list-comments --need-relation` 获取评论;已知评论 ID 且需要批量查询时,可用 `drive file.comments batch_query` 并带 `need_relation=true`raw `drive file.comments list` 仅作为低层参数兜底。
3.`docs +fetch --detail with-ids` 获取文档内容。
4. 对每条评论先看 `relation`
- 如果存在 `relation.relation`,解析这个 JSON 字符串。

View File

@@ -1,6 +1,6 @@
# Drive 评论查询、统计与回复指南
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md)reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md)获取评论列表优先使用 [`lark-drive-list-comments.md`](lark-drive-list-comments.md)reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
## 评论模式
@@ -16,14 +16,21 @@
## 查询默认口径
`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数
优先使用 `drive +list-comments`,不要优先手写 `drive file.comments list`。shortcut 默认 `--solved-status false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论仅当用户明确要求包含已解决评论时,才`--solved-status all`。只查已解决评论时传 `--solved-status true`
```bash
# 默认查询:仅未解决评论
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx","is_solved":false}'
lark-cli drive +list-comments --url '<DOC_URL>'
# 全部评论:包含已解决和未解决
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status all
# 已解决评论
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status true
# 裸 wiki token
lark-cli drive +list-comments --token '<WIKI_TOKEN>' --type wiki
# 包含已解决评论:仅当用户明确要求时使用
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx"}'
```
## 评论卡片与统计
@@ -53,12 +60,13 @@ lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"doc
## batch_query 与 list
- `drive file.comments batch_query` 用于已知评论 ID 后的批量查询,需要传入具体评论 ID 列表。
- `drive file.comments list` 用于分页获取评论列表,适合统计评论数、遍历所有评论、获取最新或最后 N 条评论等场景
- `drive +list-comments` 用于分页获取评论列表;如果要统计全量评论数、遍历包含已解决评论在内的所有评论、获取全量最新评论或最后 N 条评论,请先传 `--solved-status all` 并拉完所有分页。它会处理 URL、wiki token 和 token/type 匹配问题
- `drive file.comments list` 是原生命令。需要 shortcut 未暴露的字段时才使用。
## 评论定位字段
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。
- 其他文档类型暂不支持返回定位字段
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`
- `--need-relation` 仅 docx 生效;其他文档类型会静默忽略
## 原生 API

View File

@@ -0,0 +1,106 @@
# drive +list-comments
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
列出 doc/docx/sheet/file/slides/base(bitable) 的评论卡片。优先传 URLshortcut 会自动识别类型;如果传 wiki URL 或 `--token <wiki_token> --type wiki`,会先解析到真实文档。原生 `drive file.comments list` 仍保留用于需要特殊参数的场景。
## 命令
```bash
# 推荐:直接传 URL。默认只查未解决评论。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>"
# 查询全部评论,包括已解决和未解决。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--solved-status all
# 查询已解决评论。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--solved-status true
# 只查全文评论或局部评论。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--comment-scope whole
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--comment-scope partial
# wiki URL 会自动解包。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>"
# 裸 wiki token 也支持,但必须显式声明 --type wiki。
lark-cli drive +list-comments \
--token "<WIKI_TOKEN>" \
--type wiki
# 裸真实 token 需要声明真实类型。
lark-cli drive +list-comments \
--token "<DOCX_TOKEN>" \
--type docx \
--page-size 100
# docx 需要评论定位关系时再带 need-relation非 docx 会静默忽略。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--need-relation
# 分页续跑。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--page-size 100 \
--page-token "<NEXT_PAGE_TOKEN>"
# 预览请求链路,不发真实请求。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/wiki URLwiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | `doc``docx``sheet``file``slides``bitable``base``wiki`。传 `base`CLI 会按 `bitable` 类型处理。 |
| `--solved-status` | 否 | `false` / `true` / `all`,默认 `false``false` 查未解决评论;`true` 查已解决评论;`all` 查全部评论。 |
| `--comment-scope` | 否 | `all` / `whole` / `partial`,默认 `all``all` 查全部范围;`whole` 查全文评论;`partial` 查局部评论。 |
| `--need-reaction` | 否 | 是否返回评论卡片上的 reaction 数据;只有用户明确需要 reaction 时才带。 |
| `--need-relation` | 否 | docx 评论定位关系字段;仅 docx 生效,非 docx 静默忽略。需要定位正文时先读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。 |
| `--page-size` | 否 | 默认 50最大 100。 |
| `--page-token` | 否 | 分页游标;本 shortcut 不自动翻页,按返回的 `page_token` 继续请求下一页。 |
| `--user-id-type` | 否 | `user_id` / `union_id` / `open_id`,默认 `open_id`。 |
## 行为说明
- 默认 `--solved-status false`,即默认只查未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论;仅当用户明确要求包含已解决评论时,才传 `--solved-status all`
- `--comment-scope all` 查全部范围;`whole` 查全文评论;`partial` 查局部/选区评论。
- URL 输入时不需要传 `--type`;如果 URL 类型和显式 `--type` 冲突shortcut 会返回 validation error建议移除 `--type`
- wiki 输入会自动解析到真实文档再查询评论列表。JSON 输出不额外返回 wiki token 或 wiki node。
- 输出中的 `items` 保留评论卡片字段,外层补充 `file_token``file_type``has_more``page_token``count``count` 是当前页返回的评论卡片数。
- 如果需要批量按评论 ID 查询、获取更多回复、创建/编辑/删除回复,继续使用原生 `drive file.comments batch_query``drive file.comment.replys.*`
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"items": [],
"has_more": false,
"page_token": "",
"count": 0
}
```
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-drive-comments-guide](lark-drive-comments-guide.md) -- 评论统计、回复限制和原生 API 说明
- [lark-drive-comment-location](lark-drive-comment-location.md) -- 使用 `need_relation` 定位 docx 正文

View File

@@ -47,14 +47,11 @@
**然后按图表类型 × 身份选路径**,读对应文件按其完整 workflow 执行(含读 scene 指南、生成内容、渲染审查、交付):
按上到下匹配, 命中即停:
| 图表类型 | 身份 | 路径 |
|--------------------|-------------------------------------|------------------------------------------------|
| 思维导图、时序图、类图、饼图、甘特图 | 任何身份 | [`../routes/mermaid.md`](../routes/mermaid.md) |
| 鱼骨图、金字塔图、流程图 | `Doubao` / `Seed` | [`../routes/dsl.md`](../routes/dsl.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` / `Doubao` / `Seed` | [`../routes/svg.md`](../routes/svg.md) |
| 其他图表 | `Other` | [`../routes/dsl.md`](../routes/dsl.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` | [`../routes/svg.md`](../routes/svg.md) |
| 其他图表 | `Doubao` / `Seed` / `Other` | [`../routes/dsl.md`](../routes/dsl.md) |
> **⚠️ SVG 路径失败回退**:走 `routes/svg.md` 时,碰到以下情况之一 → **丢弃当前 SVG改读 `routes/dsl.md` 从零重画,不要逐行修补**
> - 渲染命令直接报错(语法级崩溃,不是 `--check` 的 warn/error

View File

@@ -43,58 +43,6 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
}
}
func TestDocsFetchDryRunSelectionAnchorFragmentBecomesRangeStart(t *testing.T) {
setDocsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", "https://example.larksuite.com/wiki/wikcnDryRun#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"--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/docs_ai/v1/documents/wikcnDryRun/fetch" {
t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.read_option.read_mode").String(); got != "range" {
t.Fatalf("read_mode=%q, want range\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.read_option.start_block_id").String(); got != "share-CUE3d6Ykno2fkexEvt8cGF8Wnse" {
t.Fatalf("start_block_id=%q, want selection anchor\nstdout:\n%s", got, out)
}
}
func TestDocsFetchDryRunUnsupportedSelectionAnchorFragmentStaysFull(t *testing.T) {
setDocsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", "https://example.larksuite.com/wiki/wikcnDryRun#part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.body.read_option").Raw; got != "" {
t.Fatalf("read_option=%s, want omitted for unsupported selection anchor\nstdout:\n%s", got, out)
}
}
func setDocsDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -1,9 +1,9 @@
# Drive CLI E2E Coverage
## Metrics
- Denominator: 31 leaf commands
- Covered: 10
- Coverage: 32.3%
- Denominator: 32 leaf commands
- Covered: 11
- Coverage: 34.4%
## 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`.
@@ -12,7 +12,8 @@
- 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 / 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`.
- TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, default `is_solved=false`, default omitted `is_whole`, `user_id_type=open_id`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body 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.
@@ -26,6 +27,7 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | 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 <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size`; `--user-id-type` | dry-run locks URL/token parsing, default unresolved filter, omitted all-scope filter, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
| ✓ | 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

@@ -81,4 +81,41 @@ func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
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)
}
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--token", fileToken,
"--type", "file",
"--solved-status", "all",
"--page-size", "100",
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || !driveCommentListContainsID(result.Stdout, commentID)
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
if got := gjson.Get(listResult.Stdout, "data.file_token").String(); got != fileToken {
t.Fatalf("list data.file_token=%q, want %q\nstdout:\n%s", got, fileToken, listResult.Stdout)
}
if got := gjson.Get(listResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("list data.file_type=%q, want file\nstdout:\n%s", got, listResult.Stdout)
}
if !driveCommentListContainsID(listResult.Stdout, commentID) {
t.Fatalf("list comments did not include comment_id %q\nstdout:\n%s", commentID, listResult.Stdout)
}
}
func driveCommentListContainsID(stdout, commentID string) bool {
for _, item := range gjson.Get(stdout, "data.items").Array() {
if item.Get("comment_id").String() == commentID {
return true
}
}
return false
}

View File

@@ -0,0 +1,100 @@
// 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 TestDriveListCommentsDryRun_DocxDefaults(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", "+list-comments",
"--url", "https://example.larksuite.com/docx/docxDryRunCommentList",
"--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/docxDryRunCommentList/comments" {
t.Fatalf("api.0.url=%q, want comments list\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("api.0.params.file_type=%q, want docx\nstdout:\n%s", got, out)
}
isSolved := gjson.Get(out, "api.0.params.is_solved")
if !isSolved.Exists() || isSolved.Bool() {
t.Fatalf("api.0.params.is_solved=%v, want explicit false\nstdout:\n%s", isSolved.Value(), out)
}
if gjson.Get(out, "api.0.params.is_whole").Exists() {
t.Fatalf("api.0.params.is_whole should be omitted by default\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 {
t.Fatalf("api.0.params.page_size=%d, want 50\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" {
t.Fatalf("api.0.params.user_id_type=%q, want open_id\nstdout:\n%s", got, out)
}
}
func TestDriveListCommentsDryRun_WikiToken(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", "+list-comments",
"--token", "wikiDryRunCommentList",
"--type", "wiki",
"--solved-status", "all",
"--comment-scope", "partial",
"--need-relation",
"--page-size", "99",
"--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/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunCommentList" {
t.Fatalf("api.0.params.token=%q, want wikiDryRunCommentList\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/<obj_token from step 1>/comments" {
t.Fatalf("api.1.url=%q, want resolved comments list placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.file_type").String(); got != "<obj_type from step 1>" {
t.Fatalf("api.1.params.file_type=%q, want obj_type placeholder\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.1.params.is_solved").Exists() {
t.Fatalf("api.1.params.is_solved should be omitted for solved-status all\nstdout:\n%s", out)
}
isWhole := gjson.Get(out, "api.1.params.is_whole")
if !isWhole.Exists() || isWhole.Bool() {
t.Fatalf("api.1.params.is_whole=%v, want explicit false for partial\nstdout:\n%s", isWhole.Value(), out)
}
if got := gjson.Get(out, "api.1.params.need_relation").String(); got != "<sent only when obj_type is docx>" {
t.Fatalf("api.1.params.need_relation=%q, want conditional placeholder\nstdout:\n%s", got, out)
}
}

View File

@@ -26,6 +26,14 @@ var driveDeleteVisibilityWait = clie2e.WaitOptions{
Interval: driveDeleteVisibilityPoll,
}
var driveDeleteRetry = clie2e.RetryOptions{
Attempts: 6,
InitialDelay: 2 * time.Second,
MaxDelay: 8 * time.Second,
BackoffMultiple: 2,
ShouldRetry: clie2e.ResultHasRetryableError,
}
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
// deletes it during parent cleanup.
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
@@ -80,10 +88,10 @@ func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs
defaultAs = "bot"
}
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: defaultAs,
})
}, driveDeleteRetry)
if deleteErr != nil || deleteResult == nil {
return deleteResult, deleteErr
}

View File

@@ -23,6 +23,20 @@ func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
}
func TestDeleteDriveResourceAndVerify(t *testing.T) {
t.Run("retries retryable delete contention", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_DRIVE_DELETE_RETRYABLE_ATTEMPTS", "2")
t.Setenv("FAKE_DRIVE_DELETE_STATE", filepath.Join(t.TempDir(), "delete-attempts"))
t.Setenv("FAKE_DRIVE_META_EMPTY", "1")
withFastDriveDeleteRetry(t)
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_retry", "folder", "bot")
require.NotNil(t, result)
require.NoError(t, err)
assert.Equal(t, 0, result.ExitCode)
})
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
@@ -51,11 +65,41 @@ func TestDeleteDriveResourceAndVerify(t *testing.T) {
})
}
func withFastDriveDeleteRetry(t *testing.T) {
t.Helper()
original := driveDeleteRetry
driveDeleteRetry = clie2e.RetryOptions{
Attempts: 3,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
BackoffMultiple: 2,
ShouldRetry: clie2e.ResultHasRetryableError,
}
t.Cleanup(func() {
driveDeleteRetry = original
})
}
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
t.Helper()
script := `#!/bin/sh
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
if [ -n "$FAKE_DRIVE_DELETE_RETRYABLE_ATTEMPTS" ]; then
state="$FAKE_DRIVE_DELETE_STATE"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
next=$((count + 1))
echo "$next" > "$state"
if [ "$count" -lt "$FAKE_DRIVE_DELETE_RETRYABLE_ATTEMPTS" ]; then
echo "Deleting folder fake..." >&2
echo '{"ok":false,"error":{"type":"api","code":1061045,"message":"resource contention occurred, please retry.","retryable":true}}' >&2
exit 1
fi
fi
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
exit "$FAKE_DRIVE_DELETE_EXIT"
@@ -65,6 +109,10 @@ if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
fi
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
if [ "${FAKE_DRIVE_META_EMPTY:-0}" = "1" ]; then
echo '{"ok":true,"data":{"metas":[]}}'
exit 0
fi
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
fi