diff --git a/shortcuts/drive/drive_delete.go b/shortcuts/drive/drive_delete.go new file mode 100644 index 000000000..98a331e6e --- /dev/null +++ b/shortcuts/drive/drive_delete.go @@ -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("")) + } + + 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 +} diff --git a/shortcuts/drive/drive_delete_test.go b/shortcuts/drive/drive_delete_test.go new file mode 100644 index 000000000..c66e3f6ec --- /dev/null +++ b/shortcuts/drive/drive_delete_test.go @@ -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"] != "" { + 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()) + } + } + }) + } +} diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go index da2db5b2c..100a671d5 100644 --- a/shortcuts/drive/drive_io_test.go +++ b/shortcuts/drive/drive_io_test.go @@ -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() diff --git a/shortcuts/drive/drive_move.go b/shortcuts/drive/drive_move.go index 2d2b5ed17..8eed0bcf3 100644 --- a/shortcuts/drive/drive_move.go +++ b/shortcuts/drive/drive_move.go @@ -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 diff --git a/shortcuts/drive/drive_move_common.go b/shortcuts/drive/drive_move_common.go index dfdaa0e68..d200d8cf5 100644 --- a/shortcuts/drive/drive_move_common.go +++ b/shortcuts/drive/drive_move_common.go @@ -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 } diff --git a/shortcuts/drive/drive_move_common_test.go b/shortcuts/drive/drive_move_common_test.go index 093271157..6198e58f7 100644 --- a/shortcuts/drive/drive_move_common_test.go +++ b/shortcuts/drive/drive_move_common_test.go @@ -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"`, + }, }, - }) - - 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"}, + { + name: "all polls fail", + taskCheckBody: map[string]interface{}{ + "code": 1061001, + "msg": "internal error", + }, + wantErrContains: "internal error", }, - }) - 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()) + } + } + }) } } diff --git a/shortcuts/drive/drive_task_result_test.go b/shortcuts/drive/drive_task_result_test.go index b105f4dff..6a98d0a8f 100644 --- a/shortcuts/drive/drive_task_result_test.go +++ b/shortcuts/drive/drive_task_result_test.go @@ -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()) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index e8fbad3c1..55c5e07b4 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -15,6 +15,7 @@ func Shortcuts() []common.Shortcut { DriveExportDownload, DriveImport, DriveMove, + DriveDelete, DriveTaskResult, } } diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 1fbfe019d..fb2248ddb 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -17,6 +17,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+export-download", "+import", "+move", + "+delete", "+task_result", } diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index ce30b7cf7..6926307c5 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -180,6 +180,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [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 diff --git a/skills/lark-drive/references/lark-drive-delete.md b/skills/lark-drive/references/lark-drive-delete.md new file mode 100644 index 000000000..0048364cd --- /dev/null +++ b/skills/lark-drive/references/lark-drive-delete.md @@ -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 \ + --type file \ + --yes + +# 删除在线文档 +lark-cli drive +delete \ + --file-token \ + --type docx \ + --yes + +# 删除文件夹(异步操作,会自动有限轮询任务状态) +lark-cli drive +delete \ + --file-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_check` 的服务端状态通常是 `success`、`fail`、`process` + +## 推荐续跑方式 + +```bash +# 第一步:先直接删除文件夹 +lark-cli drive +delete \ + --file-token \ + --type folder \ + --yes + +# 如果返回 ready=false / timed_out=true,再继续查 +lark-cli drive +task_result \ + --scenario task_check \ + --task-id +``` + +## 限制 + +- 该 shortcut 仅支持云空间文件或文件夹,不支持 wiki 文档 +- 该接口不支持并发调用 +- 调用频率上限为 5 QPS 且 10000 次/天 + +## 权限要求 + +- 删除文件时,调用身份需要满足以下其一: +- 是文件所有者,并且拥有该文件所在父文件夹的编辑权限 +- 不是文件所有者,但拥有该父文件夹的 owner 或 full access 权限 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数