mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(drive): add drive folder delete shortcut with async task polling (#415)
Change-Id: Ifb34f67296b800501a1b4960e02d5fed3382b84a
This commit is contained in:
148
shortcuts/drive/drive_delete.go
Normal file
148
shortcuts/drive/drive_delete.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveDeleteAllowedTypes = map[string]bool{
|
||||
"file": true,
|
||||
"docx": true,
|
||||
"bitable": true,
|
||||
"doc": true,
|
||||
"sheet": true,
|
||||
"mindnote": true,
|
||||
"folder": true,
|
||||
"shortcut": true,
|
||||
"slides": true,
|
||||
}
|
||||
|
||||
// driveDeleteSpec contains the normalized input needed to issue a delete
|
||||
// request against the Drive files endpoint.
|
||||
type driveDeleteSpec struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
}
|
||||
|
||||
// DriveDelete deletes a Drive file or folder and handles the async task
|
||||
// polling required by folder deletes.
|
||||
var DriveDelete = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+delete",
|
||||
Description: "Delete a file or folder in Drive",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"space:document:delete"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
|
||||
{Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveDeleteSpec(driveDeleteSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveDeleteSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Delete file or folder in Drive")
|
||||
|
||||
dry.DELETE("/open-apis/drive/v1/files/:file_token").
|
||||
Desc("[1] Delete file/folder").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"type": spec.FileType})
|
||||
|
||||
if spec.FileType == "folder" {
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[2] Poll async task status (for folder delete)").
|
||||
Params(driveTaskCheckParams("<task_id>"))
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveDeleteSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken))
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
|
||||
map[string]interface{}{"type": spec.FileType},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.FileType == "folder" {
|
||||
taskID := common.GetString(data, "task_id")
|
||||
if taskID == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
|
||||
|
||||
status, ready, err := pollDriveTaskCheck(runtime, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"status": status.StatusLabel(),
|
||||
"file_token": spec.FileToken,
|
||||
"type": spec.FileType,
|
||||
"ready": ready,
|
||||
}
|
||||
if ready {
|
||||
out["deleted"] = true
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"deleted": true,
|
||||
"file_token": spec.FileToken,
|
||||
"type": spec.FileType,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FileType == "wiki" {
|
||||
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported")
|
||||
}
|
||||
if !driveDeleteAllowedTypes[spec.FileType] {
|
||||
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
224
shortcuts/drive/drive_delete_test.go
Normal file
224
shortcuts/drive/drive_delete_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveDeleteSpec(driveDeleteSpec{
|
||||
FileToken: "wiki_token_test",
|
||||
FileType: "wiki",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected wiki type error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "wiki documents are not supported") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +delete"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "folder"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveDelete.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Method != "DELETE" {
|
||||
t.Fatalf("first method = %q, want DELETE", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].Params["type"] != "folder" {
|
||||
t.Fatalf("delete params = %#v", got.API[0].Params)
|
||||
}
|
||||
if got.API[1].Params["task_id"] != "<task_id>" {
|
||||
t.Fatalf("task check params = %#v", got.API[1].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteRequiresYes(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteFileSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/file_token_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
|
||||
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
|
||||
t.Fatalf("stdout missing file token: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskCheckBody map[string]interface{}
|
||||
wantErrContains string
|
||||
wantStdout []string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"task_id": "task_123"`,
|
||||
`"deleted": true`,
|
||||
`"ready": true`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "process"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"ready": false`,
|
||||
`"timed_out": true`,
|
||||
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failed",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "fail"},
|
||||
},
|
||||
wantErrContains: "folder task failed",
|
||||
},
|
||||
{
|
||||
name: "task_check error",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 1061001,
|
||||
"msg": "internal error",
|
||||
},
|
||||
wantErrContains: "internal error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/fld_src",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: tt.taskCheckBody,
|
||||
})
|
||||
|
||||
withSingleDriveTaskCheckPoll(t)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if tt.wantErrContains != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected delete failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, needle := range tt.wantStdout {
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
|
||||
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveTaskCheckPollMu sync.Mutex
|
||||
|
||||
func driveTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -37,6 +40,18 @@ func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func withSingleDriveTaskCheckPoll(t *testing.T) {
|
||||
t.Helper()
|
||||
driveTaskCheckPollMu.Lock()
|
||||
|
||||
prevAttempts, prevInterval := driveTaskCheckPollAttempts, driveTaskCheckPollInterval
|
||||
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = prevAttempts, prevInterval
|
||||
driveTaskCheckPollMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func withDriveWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
cwd, err := os.Getwd()
|
||||
|
||||
@@ -115,7 +115,7 @@ var DriveMove = common.Shortcut{
|
||||
"ready": ready,
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID)
|
||||
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
driveMovePollAttempts = 30
|
||||
driveMovePollInterval = 2 * time.Second
|
||||
driveTaskCheckPollAttempts = 30
|
||||
driveTaskCheckPollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move
|
||||
@@ -61,7 +61,7 @@ func validateDriveMoveSpec(spec driveMoveSpec) error {
|
||||
}
|
||||
|
||||
// driveTaskCheckStatus represents the status payload returned by
|
||||
// /drive/v1/files/task_check for async folder operations.
|
||||
// /drive/v1/files/task_check for async folder move/delete operations.
|
||||
type driveTaskCheckStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
@@ -72,7 +72,11 @@ func (s driveTaskCheckStatus) Ready() bool {
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Failed() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(s.Status), "failed")
|
||||
status := strings.TrimSpace(s.Status)
|
||||
// The shared task_check endpoint is reused by multiple async flows. Some
|
||||
// backends return "failed", while folder delete can return the shorter
|
||||
// terminal state "fail".
|
||||
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Pending() bool {
|
||||
@@ -91,8 +95,8 @@ func (s driveTaskCheckStatus) StatusLabel() string {
|
||||
|
||||
// driveTaskCheckResultCommand prints the resume command shown when bounded
|
||||
// polling ends before the backend task completes.
|
||||
func driveTaskCheckResultCommand(taskID string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s", taskID)
|
||||
func driveTaskCheckResultCommand(taskID, as string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s --as %s", taskID, as)
|
||||
}
|
||||
|
||||
// driveTaskCheckParams keeps the task_check query parameter shape in one place
|
||||
@@ -130,31 +134,42 @@ func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) drive
|
||||
}
|
||||
}
|
||||
|
||||
// pollDriveTaskCheck polls the backend for a bounded period and returns the
|
||||
// last seen status so callers can emit a follow-up command when needed.
|
||||
// pollDriveTaskCheck polls the shared task_check endpoint for a bounded period
|
||||
// and returns the last seen status so callers can emit a follow-up command
|
||||
// when needed.
|
||||
func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) {
|
||||
lastStatus := driveTaskCheckStatus{TaskID: taskID}
|
||||
for attempt := 1; attempt <= driveMovePollAttempts; attempt++ {
|
||||
var (
|
||||
seenStatus bool
|
||||
lastErr error
|
||||
)
|
||||
for attempt := 1; attempt <= driveTaskCheckPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(driveMovePollInterval)
|
||||
time.Sleep(driveTaskCheckPollInterval)
|
||||
}
|
||||
|
||||
status, err := getDriveTaskCheckStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err)
|
||||
continue
|
||||
}
|
||||
seenStatus = true
|
||||
lastStatus = status
|
||||
// Success and failure are terminal backend states. Any other value is kept
|
||||
// as pending so the caller can decide whether to continue or resume later.
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move completed successfully.\n")
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder move task failed")
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder task failed")
|
||||
}
|
||||
}
|
||||
|
||||
if !seenStatus && lastErr != nil {
|
||||
return driveTaskCheckStatus{}, false, lastErr
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
@@ -102,91 +102,91 @@ func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
func TestDriveMoveFolderTaskCheckOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskCheckBody map[string]interface{}
|
||||
wantErrContains string
|
||||
wantStdout []string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"task_id": "task_123"`,
|
||||
`"ready": true`,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
{
|
||||
name: "timeout",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"ready": false`,
|
||||
`"timed_out": true`,
|
||||
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all polls fail",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 1061001,
|
||||
"msg": "internal error",
|
||||
},
|
||||
wantErrContains: "internal error",
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_123"`)) {
|
||||
t.Fatalf("stdout missing task id: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
|
||||
t.Fatalf("stdout missing ready=true: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
|
||||
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123"`)) {
|
||||
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: tt.taskCheckBody,
|
||||
})
|
||||
|
||||
withSingleDriveTaskCheckPoll(t)
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if tt.wantErrContains != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected task_check polling error, got nil")
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte(tt.wantErrContains)) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, needle := range tt.wantStdout {
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
|
||||
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,3 +246,34 @@ func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
|
||||
t.Fatalf("stdout missing failed=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "fail"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "task_check",
|
||||
"--task-id", "task_123",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"status": "fail"`)) {
|
||||
t.Fatalf("stdout missing fail status: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": true`)) {
|
||||
t.Fatalf("stdout missing failed=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
DriveMove,
|
||||
DriveDelete,
|
||||
DriveTaskResult,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+export-download",
|
||||
"+import",
|
||||
"+move",
|
||||
"+delete",
|
||||
"+task_result",
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
|
||||
## API Resources
|
||||
|
||||
79
skills/lark-drive/references/lark-drive-delete.md
Normal file
79
skills/lark-drive/references/lark-drive-delete.md
Normal file
@@ -0,0 +1,79 @@
|
||||
|
||||
# drive +delete
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
删除云空间内的文件或文件夹。删除后资源会进入回收站。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 删除普通文件
|
||||
lark-cli drive +delete \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file \
|
||||
--yes
|
||||
|
||||
# 删除在线文档
|
||||
lark-cli drive +delete \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx \
|
||||
--yes
|
||||
|
||||
# 删除文件夹(异步操作,会自动有限轮询任务状态)
|
||||
lark-cli drive +delete \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 需要删除的文件或文件夹 token |
|
||||
| `--type` | 是 | 文件类型,可选值:`file`、`docx`、`bitable`、`doc`、`sheet`、`mindnote`、`folder`、`shortcut`、`slides` |
|
||||
| `--yes` | 是 | 确认执行高风险删除操作 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **普通文件删除**:同步操作,成功时直接返回 `deleted=true`
|
||||
- **文件夹删除**:异步操作,接口返回 `task_id`,shortcut 会先做有限轮询;如果在轮询窗口内完成,则直接返回成功结果
|
||||
- **轮询超时不是失败**:文件夹删除内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id`、`status`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
- **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>` 继续查询
|
||||
- **状态值**:`task_check` 的服务端状态通常是 `success`、`fail`、`process`
|
||||
|
||||
## 推荐续跑方式
|
||||
|
||||
```bash
|
||||
# 第一步:先直接删除文件夹
|
||||
lark-cli drive +delete \
|
||||
--file-token <FOLDER_TOKEN> \
|
||||
--type folder \
|
||||
--yes
|
||||
|
||||
# 如果返回 ready=false / timed_out=true,再继续查
|
||||
lark-cli drive +task_result \
|
||||
--scenario task_check \
|
||||
--task-id <TASK_ID>
|
||||
```
|
||||
|
||||
## 限制
|
||||
|
||||
- 该 shortcut 仅支持云空间文件或文件夹,不支持 wiki 文档
|
||||
- 该接口不支持并发调用
|
||||
- 调用频率上限为 5 QPS 且 10000 次/天
|
||||
|
||||
## 权限要求
|
||||
|
||||
- 删除文件时,调用身份需要满足以下其一:
|
||||
- 是文件所有者,并且拥有该文件所在父文件夹的编辑权限
|
||||
- 不是文件所有者,但拥有该父文件夹的 owner 或 full access 权限
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
Reference in New Issue
Block a user