mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
1 Commits
feat/apps-
...
codex/insp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b54abe817c |
@@ -23,7 +23,7 @@ func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool
|
|||||||
body["with_url"] = true
|
body["with_url"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := runtime.CallAPI(
|
data, err := runtime.CallAPITyped(
|
||||||
"POST",
|
"POST",
|
||||||
"/open-apis/drive/v1/metas/batch_query",
|
"/open-apis/drive/v1/metas/batch_query",
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -103,6 +104,13 @@ func TestFetchDriveMetaTitle(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("FetchDriveMetaTitle() expected error, got 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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
driveInspectRateLimitRetries = 2
|
||||||
|
driveInspectRetryInitialBackoff = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
var driveInspectAfter = time.After
|
||||||
|
|
||||||
var DriveInspect = common.Shortcut{
|
var DriveInspect = common.Shortcut{
|
||||||
Service: "drive",
|
Service: "drive",
|
||||||
Command: "+inspect",
|
Command: "+inspect",
|
||||||
@@ -35,32 +43,15 @@ var DriveInspect = common.Shortcut{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
raw := strings.TrimSpace(runtime.Str("url"))
|
if _, err := driveInspectResolveRef(runtime); err != nil {
|
||||||
if raw == "" {
|
return err
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
raw := strings.TrimSpace(runtime.Str("url"))
|
ref, err := driveInspectResolveRef(runtime)
|
||||||
ref, ok := common.ParseResourceURL(raw)
|
if err != nil {
|
||||||
if !ok {
|
return common.NewDryRunAPI()
|
||||||
ref = common.ResourceRef{
|
|
||||||
Type: strings.TrimSpace(runtime.Str("type")),
|
|
||||||
Token: raw,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dry := common.NewDryRunAPI()
|
dry := common.NewDryRunAPI()
|
||||||
@@ -91,15 +82,9 @@ var DriveInspect = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
raw := strings.TrimSpace(runtime.Str("url"))
|
raw := strings.TrimSpace(runtime.Str("url"))
|
||||||
|
ref, err := driveInspectResolveRef(runtime)
|
||||||
// Step 1: Parse URL to extract {type, token}.
|
if err != nil {
|
||||||
ref, ok := common.ParseResourceURL(raw)
|
return err
|
||||||
if !ok {
|
|
||||||
// Bare token: use --type.
|
|
||||||
ref = common.ResourceRef{
|
|
||||||
Type: strings.TrimSpace(runtime.Str("type")),
|
|
||||||
Token: raw,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inputURL := raw
|
inputURL := raw
|
||||||
@@ -111,14 +96,19 @@ var DriveInspect = common.Shortcut{
|
|||||||
// Step 2: If type is "wiki", unwrap via get_node API.
|
// Step 2: If type is "wiki", unwrap via get_node API.
|
||||||
if docType == "wiki" {
|
if docType == "wiki" {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
|
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
|
||||||
data, err := runtime.CallAPITyped(
|
data, err := driveInspectCallWithRetry(
|
||||||
"GET",
|
ctx,
|
||||||
"/open-apis/wiki/v2/spaces/get_node",
|
func() (map[string]interface{}, error) {
|
||||||
map[string]interface{}{"token": docToken},
|
return runtime.CallAPITyped(
|
||||||
nil,
|
"GET",
|
||||||
|
"/open-apis/wiki/v2/spaces/get_node",
|
||||||
|
map[string]interface{}{"token": docToken},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return driveInspectAnnotateError("resolve_wiki", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
node := common.GetMap(data, "node")
|
node := common.GetMap(data, "node")
|
||||||
@@ -145,9 +135,9 @@ var DriveInspect = common.Shortcut{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Call batch_query to verify and get title.
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return driveInspectAnnotateError("query_meta", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Build the resolved URL.
|
// Step 4: Build the resolved URL.
|
||||||
@@ -181,3 +171,116 @@ var DriveInspect = common.Shortcut{
|
|||||||
return nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"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) {
|
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
|
||||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||||
cmd.Flags().String("url", "", "")
|
cmd.Flags().String("url", "", "")
|
||||||
@@ -540,6 +571,76 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for batch_query failure, got 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) {
|
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user