mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
1 Commits
feat/batch
...
codex/insp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b54abe817c |
@@ -23,7 +23,7 @@ func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool
|
||||
body["with_url"] = true
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -103,6 +104,13 @@ func TestFetchDriveMetaTitle(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Code != 99991668 {
|
||||
t.Fatalf("code = %d, want 99991668", p.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,19 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
driveInspectRateLimitRetries = 2
|
||||
driveInspectRetryInitialBackoff = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
var driveInspectAfter = time.After
|
||||
|
||||
var DriveInspect = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+inspect",
|
||||
@@ -35,32 +43,15 @@ var DriveInspect = common.Shortcut{
|
||||
},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
if raw == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
|
||||
}
|
||||
|
||||
_, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Not a recognized URL pattern.
|
||||
if strings.Contains(raw, "://") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
|
||||
}
|
||||
// Bare token: --type is required.
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
|
||||
}
|
||||
if _, err := driveInspectResolveRef(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
ref, err := driveInspectResolveRef(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
@@ -91,15 +82,9 @@ var DriveInspect = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
|
||||
// Step 1: Parse URL to extract {type, token}.
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Bare token: use --type.
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
ref, err := driveInspectResolveRef(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inputURL := raw
|
||||
@@ -111,14 +96,19 @@ var DriveInspect = common.Shortcut{
|
||||
// Step 2: If type is "wiki", unwrap via get_node API.
|
||||
if docType == "wiki" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": docToken},
|
||||
nil,
|
||||
data, err := driveInspectCallWithRetry(
|
||||
ctx,
|
||||
func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": docToken},
|
||||
nil,
|
||||
)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return driveInspectAnnotateError("resolve_wiki", err)
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
@@ -145,9 +135,9 @@ var DriveInspect = common.Shortcut{
|
||||
}
|
||||
|
||||
// Step 3: Call batch_query to verify and get title.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
|
||||
title, err := driveInspectFetchMetaTitle(ctx, runtime, docToken, docType)
|
||||
if err != nil {
|
||||
return err
|
||||
return driveInspectAnnotateError("query_meta", err)
|
||||
}
|
||||
|
||||
// Step 4: Build the resolved URL.
|
||||
@@ -181,3 +171,116 @@ var DriveInspect = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func driveInspectResolveRef(runtime *common.RuntimeContext) (common.ResourceRef, error) {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
if raw == "" {
|
||||
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
|
||||
}
|
||||
|
||||
inputType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if ok {
|
||||
if inputType != "" && inputType != ref.Type {
|
||||
return common.ResourceRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments and pass only the raw token with --type", raw).WithParam("--url")
|
||||
}
|
||||
if inputType == "" {
|
||||
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
|
||||
}
|
||||
return common.ResourceRef{Type: inputType, Token: raw}, nil
|
||||
}
|
||||
|
||||
func driveInspectFetchMetaTitle(ctx context.Context, runtime *common.RuntimeContext, token, docType string) (string, error) {
|
||||
var title string
|
||||
_, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
|
||||
got, callErr := common.FetchDriveMeta(runtime, token, docType, false)
|
||||
if callErr != nil {
|
||||
return nil, callErr
|
||||
}
|
||||
title = got.Title
|
||||
return map[string]interface{}{"title": got.Title}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func driveInspectCallWithRetry(ctx context.Context, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= driveInspectRateLimitRetries; attempt++ {
|
||||
data, err := call()
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !driveInspectShouldRetry(err) || attempt == driveInspectRateLimitRetries {
|
||||
return nil, err
|
||||
}
|
||||
backoff := driveInspectRetryInitialBackoff * time.Duration(1<<attempt)
|
||||
if waitErr := driveInspectWait(ctx, backoff); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func driveInspectShouldRetry(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem == nil {
|
||||
return false
|
||||
}
|
||||
return problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400 || problem.Retryable
|
||||
}
|
||||
|
||||
func driveInspectWait(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errs.WrapInternal(ctx.Err())
|
||||
case <-driveInspectAfter(d):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func driveInspectAnnotateError(stage string, err error) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem == nil {
|
||||
return err
|
||||
}
|
||||
label := map[string]string{
|
||||
"resolve_wiki": "resolve wiki node",
|
||||
"query_meta": "query document metadata",
|
||||
}[stage]
|
||||
if label == "" {
|
||||
label = stage
|
||||
}
|
||||
problem.Message = fmt.Sprintf("%s failed: %s", label, problem.Message)
|
||||
if strings.TrimSpace(problem.Hint) == "" {
|
||||
switch stage {
|
||||
case "resolve_wiki":
|
||||
problem.Hint = "check that the wiki URL/token is valid and that the current identity can read the wiki node"
|
||||
case "query_meta":
|
||||
problem.Hint = "check that the resolved document still exists and that the current identity can read its metadata"
|
||||
}
|
||||
} else if !strings.Contains(problem.Hint, label) {
|
||||
problem.Hint = label + ": " + problem.Hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -83,6 +86,34 @@ func TestDriveInspectValidate_BareTokenWithType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_URLTypeConflict(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnBareToken")
|
||||
_ = cmd.Flags().Set("type", "sheet")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for conflicting --type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_BareTokenWithPathFragment(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken/extra")
|
||||
_ = cmd.Flags().Set("type", "docx")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token with path fragment, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
@@ -540,6 +571,76 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for batch_query failure, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "query document metadata failed") {
|
||||
t.Fatalf("message = %q, want query document metadata prefix", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_RetriesRateLimitOnWikiResolve(t *testing.T) {
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "request trigger frequency limit",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "doxcnUnwrapped",
|
||||
"space_id": "space123",
|
||||
"node_token": "wikcnNodeToken",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnUnwrapped", "doc_type": "docx", "title": "Wiki Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
origAfter := driveInspectAfter
|
||||
driveInspectAfter = func(time.Duration) <-chan time.Time {
|
||||
ch := make(chan time.Time, 1)
|
||||
ch <- time.Now()
|
||||
return ch
|
||||
}
|
||||
defer func() { driveInspectAfter = origAfter }()
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error after retry: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["token"] != "doxcnUnwrapped" {
|
||||
t.Fatalf("token = %v, want doxcnUnwrapped", data["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user