diff --git a/shortcuts/common/permission_grant.go b/shortcuts/common/permission_grant.go new file mode 100644 index 00000000..68be2f46 --- /dev/null +++ b/shortcuts/common/permission_grant.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/internal/validate" +) + +const ( + PermissionGrantGranted = "granted" + PermissionGrantSkipped = "skipped" + PermissionGrantFailed = "failed" + permissionGrantPerm = "full_access" + permissionGrantPermHint = "可管理权限" +) + +// AutoGrantCurrentUserDrivePermission grants full_access on a newly created +// Drive resource to the current CLI user when the shortcut runs as bot. +// +// Callers should attach the returned result only when it is non-nil. +func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourceType string) map[string]interface{} { + if runtime == nil || !runtime.IsBot() { + return nil + } + + token = strings.TrimSpace(token) + resourceType = strings.TrimSpace(resourceType) + if token == "" || resourceType == "" { + return buildPermissionGrantResult( + PermissionGrantSkipped, + "", + fmt.Sprintf("The operation did not return a permission target (missing token/type), so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()), + ) + } + + return autoGrantCurrentUserDrivePermission(runtime, token, resourceType) +} + +func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourceType string) map[string]interface{} { + userOpenID := strings.TrimSpace(runtime.UserOpenId()) + if userOpenID == "" { + return buildPermissionGrantResult( + PermissionGrantSkipped, + "", + fmt.Sprintf("Resource was created with bot identity, but no current CLI user open_id is configured, so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()), + ) + } + + body := map[string]interface{}{ + "member_type": "openid", + "member_id": userOpenID, + "perm": permissionGrantPerm, + "type": "user", + } + if permType := permissionGrantPermType(resourceType); permType != "" { + body["perm_type"] = permType + } + + _, err := runtime.CallAPI( + "POST", + fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(token)), + map[string]interface{}{ + "type": resourceType, + "need_notification": false, + }, + body, + ) + if err != nil { + return buildPermissionGrantResult( + PermissionGrantFailed, + userOpenID, + fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), compactPermissionGrantError(err)), + ) + } + + return buildPermissionGrantResult( + PermissionGrantGranted, + userOpenID, + fmt.Sprintf("Granted the current CLI user %s on the new %s.", permissionGrantPermMessage(), permissionTargetLabel(resourceType)), + ) +} + +func buildPermissionGrantResult(status, userOpenID, message string) map[string]interface{} { + result := map[string]interface{}{ + "status": status, + "perm": permissionGrantPerm, + "message": message, + } + if userOpenID != "" { + result["user_open_id"] = userOpenID + result["member_type"] = "openid" + } + return result +} + +func permissionGrantPermMessage() string { + return permissionGrantPerm + " (" + permissionGrantPermHint + ")" +} + +func permissionGrantPermType(resourceType string) string { + switch resourceType { + case "wiki": + return "container" + default: + return "" + } +} + +func permissionTargetLabel(resourceType string) string { + switch resourceType { + case "wiki": + return "wiki node" + case "doc", "docx": + return "document" + case "sheet": + return "spreadsheet" + case "bitable", "base": + return "base" + case "file": + return "file" + case "folder": + return "folder" + default: + return "resource" + } +} + +func compactPermissionGrantError(err error) string { + if err == nil { + return "" + } + return strings.Join(strings.Fields(err.Error()), " ") +} diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go index 87152c36..69ec15c8 100644 --- a/shortcuts/doc/docs_create.go +++ b/shortcuts/doc/docs_create.go @@ -5,6 +5,7 @@ package doc import ( "context" + "strings" "github.com/larksuite/cli/shortcuts/common" ) @@ -40,51 +41,90 @@ var DocsCreate = common.Shortcut{ return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - args := map[string]interface{}{ - "markdown": runtime.Str("markdown"), - } - if v := runtime.Str("title"); v != "" { - args["title"] = v - } - if v := runtime.Str("folder-token"); v != "" { - args["folder_token"] = v - } - if v := runtime.Str("wiki-node"); v != "" { - args["wiki_node"] = v - } - if v := runtime.Str("wiki-space"); v != "" { - args["wiki_space"] = v - } - return common.NewDryRunAPI(). + args := buildDocsCreateArgs(runtime) + d := common.NewDryRunAPI(). POST(common.MCPEndpoint(runtime.Config.Brand)). Desc("MCP tool: create-doc"). Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}). Set("mcp_tool", "create-doc").Set("args", args) + if runtime.IsBot() { + d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.") + } + return d }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - args := map[string]interface{}{ - "markdown": runtime.Str("markdown"), - } - if v := runtime.Str("title"); v != "" { - args["title"] = v - } - if v := runtime.Str("folder-token"); v != "" { - args["folder_token"] = v - } - if v := runtime.Str("wiki-node"); v != "" { - args["wiki_node"] = v - } - if v := runtime.Str("wiki-space"); v != "" { - args["wiki_space"] = v - } - + args := buildDocsCreateArgs(runtime) result, err := common.CallMCPTool(runtime, "create-doc", args) if err != nil { return err } + augmentDocsCreateResult(runtime, result) normalizeDocsUpdateResult(result, runtime.Str("markdown")) runtime.Out(result, nil) return nil }, } + +func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} { + args := map[string]interface{}{ + "markdown": runtime.Str("markdown"), + } + if v := runtime.Str("title"); v != "" { + args["title"] = v + } + if v := runtime.Str("folder-token"); v != "" { + args["folder_token"] = v + } + if v := runtime.Str("wiki-node"); v != "" { + args["wiki_node"] = v + } + if v := runtime.Str("wiki-space"); v != "" { + args["wiki_space"] = v + } + return args +} + +type docsPermissionTarget struct { + Token string + Type string +} + +func augmentDocsCreateResult(runtime *common.RuntimeContext, result map[string]interface{}) { + target := selectDocsPermissionTarget(result) + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil { + result["permission_grant"] = grant + } +} + +func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTarget { + if ref, ok := parseDocsPermissionTargetFromURL(common.GetString(result, "doc_url")); ok { + return ref + } + + docID := strings.TrimSpace(common.GetString(result, "doc_id")) + if docID != "" { + return docsPermissionTarget{Token: docID, Type: "docx"} + } + return docsPermissionTarget{} +} + +func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) { + if strings.TrimSpace(docURL) == "" { + return docsPermissionTarget{}, false + } + + ref, err := parseDocumentRef(docURL) + if err != nil { + return docsPermissionTarget{}, false + } + + switch ref.Kind { + case "wiki": + return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true + case "doc", "docx": + return docsPermissionTarget{Token: ref.Token, Type: ref.Kind}, true + default: + return docsPermissionTarget{}, false + } +} diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go new file mode 100644 index 00000000..ad00074a --- /dev/null +++ b/shortcuts/doc/docs_create_test.go @@ -0,0 +1,240 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestDocsCreateBotAutoGrantSuccess(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user")) + registerDocsCreateMCPStub(reg, map[string]interface{}{ + "doc_id": "doxcn_new_doc", + "doc_url": "https://example.feishu.cn/docx/doxcn_new_doc", + "message": "文档创建成功", + }) + + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "member": map[string]interface{}{ + "member_id": "ou_current_user", + "member_type": "openid", + "perm": "full_access", + }, + }, + }, + } + reg.Register(permStub) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目计划", + "--markdown", "## 目标", + "--as", "bot", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsCreateEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantGranted { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) + } + if grant["user_open_id"] != "ou_current_user" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user") + } + if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." { + t.Fatalf("permission_grant.message = %#v", grant["message"]) + } + + var body map[string]interface{} + if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil { + t.Fatalf("failed to parse permission request body: %v", err) + } + if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", body) + } +} + +func TestDocsCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + registerDocsCreateMCPStub(reg, map[string]interface{}{ + "doc_id": "doxcn_new_doc", + "doc_url": "https://example.feishu.cn/docx/doxcn_new_doc", + "message": "文档创建成功", + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--markdown", "## 内容", + "--as", "bot", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsCreateEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantSkipped { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped) + } + if _, ok := grant["user_open_id"]; ok { + t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant) + } +} + +func TestDocsCreateUserSkipsPermissionGrantAugmentation(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user")) + registerDocsCreateMCPStub(reg, map[string]interface{}{ + "doc_id": "doxcn_new_doc", + "doc_url": "https://example.feishu.cn/docx/doxcn_new_doc", + "message": "文档创建成功", + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--markdown", "## 内容", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsCreateEnvelope(t, stdout) + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode output: %#v", data) + } +} + +func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user")) + registerDocsCreateMCPStub(reg, map[string]interface{}{ + "doc_id": "doxcn_new_doc", + "doc_url": "https://example.feishu.cn/wiki/wikcn_new_node", + "message": "文档创建成功", + }) + + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/wikcn_new_node/members", + Body: map[string]interface{}{ + "code": 230001, + "msg": "no permission", + }, + } + reg.Register(permStub) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--markdown", "## 内容", + "--wiki-space", "my_library", + "--as", "bot", + }) + if err != nil { + t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err) + } + + data := decodeDocsCreateEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantFailed { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed) + } + if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") { + t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"]) + } + if !strings.Contains(grant["message"].(string), "retry later") { + t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"]) + } + + var body map[string]interface{} + if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil { + t.Fatalf("failed to parse permission request body: %v", err) + } + if body["perm_type"] != "container" { + t.Fatalf("permission request perm_type = %#v, want %q", body["perm_type"], "container") + } +} + +func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig { + t.Helper() + + replacer := strings.NewReplacer("/", "-", " ", "-") + suffix := replacer.Replace(strings.ToLower(t.Name())) + return &core.CliConfig{ + AppID: "test-docs-create-" + suffix, + AppSecret: "secret-docs-create-" + suffix, + Brand: core.BrandFeishu, + UserOpenId: userOpenID, + } +} + +func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) { + payload, _ := json.Marshal(result) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/mcp", + Body: map[string]interface{}{ + "result": map[string]interface{}{ + "content": []map[string]interface{}{ + { + "type": "text", + "text": string(payload), + }, + }, + }, + }, + }) +} + +func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + + parent := &cobra.Command{Use: "docs"} + DocsCreate.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func decodeDocsCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data in output envelope: %#v", envelope) + } + return data +} diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index 9c25b2af..a10223a1 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -66,6 +66,9 @@ var DriveImport = common.Shortcut{ dry.GET("/open-apis/drive/v1/import_tasks/:ticket"). Desc("[3] Poll import task result"). Set("ticket", "") + if runtime.IsBot() { + dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.") + } return dry }, @@ -133,6 +136,11 @@ var DriveImport = common.Shortcut{ out["timed_out"] = true out["next_command"] = nextCommand } + if ready { + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil { + out["permission_grant"] = grant + } + } runtime.Out(out, nil) return nil diff --git a/shortcuts/drive/drive_import_common_test.go b/shortcuts/drive/drive_import_common_test.go index c546134c..69daba4e 100644 --- a/shortcuts/drive/drive_import_common_test.go +++ b/shortcuts/drive/drive_import_common_test.go @@ -196,6 +196,9 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) { if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario import --ticket tk_import"`)) { t.Fatalf("stdout missing follow-up command: %s", stdout.String()) } + if bytes.Contains(stdout.Bytes(), []byte(`"permission_grant"`)) { + t.Fatalf("stdout should not include permission_grant before import is ready: %s", stdout.String()) + } } func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) { diff --git a/shortcuts/drive/drive_permission_grant_test.go b/shortcuts/drive/drive_permission_grant_test.go new file mode 100644 index 00000000..99056c68 --- /dev/null +++ b/shortcuts/drive/drive_permission_grant_test.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestDriveUploadBotAutoGrantSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user")) + registerDriveBotTokenStub(reg) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "file_token": "file_uploaded", + }, + }, + }) + + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/file_uploaded/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } + reg.Register(permStub) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "report.pdf", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantGranted { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) + } + if grant["user_open_id"] != "ou_current_user" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user") + } + if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new file." { + t.Fatalf("permission_grant.message = %#v", grant["message"]) + } + + body := decodeCapturedJSONBody(t, permStub) + if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", body) + } +} + +func TestDriveImportBotAutoGrantSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user")) + registerDriveBotTokenStub(reg) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "file_token": "file_media", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/import_tasks", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "ticket": "tk_import", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/import_tasks/tk_import", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "type": "docx", + "job_status": 0, + "token": "doxcn_imported", + "url": "https://example.feishu.cn/docx/doxcn_imported", + }, + }, + }, + }) + + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/doxcn_imported/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } + reg.Register(permStub) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("README.md", []byte("# Title"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + prevAttempts, prevInterval := driveImportPollAttempts, driveImportPollInterval + driveImportPollAttempts, driveImportPollInterval = 1, 0 + t.Cleanup(func() { + driveImportPollAttempts, driveImportPollInterval = prevAttempts, prevInterval + }) + + err := mountAndRunDrive(t, DriveImport, []string{ + "+import", + "--file", "README.md", + "--type", "docx", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantGranted { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) + } + if grant["user_open_id"] != "ou_current_user" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user") + } + + body := decodeCapturedJSONBody(t, permStub) + if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", body) + } +} + +func TestDriveUploadUserSkipsPermissionGrantAugmentation(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user")) + registerDriveBotTokenStub(reg) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "file_token": "file_uploaded", + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "report.pdf", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode output: %#v", data) + } +} + +func drivePermissionGrantTestConfig(t *testing.T, userOpenID string) *core.CliConfig { + t.Helper() + + replacer := strings.NewReplacer("/", "-", " ", "-") + suffix := replacer.Replace(strings.ToLower(t.Name())) + return &core.CliConfig{ + AppID: "drive-permission-test-" + suffix, + AppSecret: "drive-permission-secret-" + suffix, + Brand: core.BrandFeishu, + UserOpenId: userOpenID, + } +} + +func decodeDriveEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data in output envelope: %#v", envelope) + } + return data +} + +func registerDriveBotTokenStub(reg *httpmock.Registry) { + _ = reg +} + +func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} { + t.Helper() + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("failed to decode captured request body: %v\nraw=%s", err, string(stub.CapturedBody)) + } + return body +} diff --git a/shortcuts/drive/drive_task_result.go b/shortcuts/drive/drive_task_result.go index 7b52c3d3..6b90db9a 100644 --- a/shortcuts/drive/drive_task_result.go +++ b/shortcuts/drive/drive_task_result.go @@ -111,7 +111,7 @@ var DriveTaskResult = common.Shortcut{ // the CLI surface uniform for resume-on-timeout workflows. switch scenario { case "import": - result, err = queryImportTask(runtime, ticket) + result, err = queryImportTaskAndAutoGrantPermission(runtime, ticket) case "export": result, err = queryExportTask(runtime, ticket, fileToken) case "task_check": @@ -127,14 +127,16 @@ var DriveTaskResult = common.Shortcut{ }, } -// queryImportTask returns a stable, shortcut-friendly view of the import task. -func queryImportTask(runtime *common.RuntimeContext, ticket string) (map[string]interface{}, error) { +// queryImportTaskAndAutoGrantPermission returns a stable, shortcut-friendly +// view of the import task and, in bot mode, retries the current-user +// permission grant once the imported cloud document becomes ready. +func queryImportTaskAndAutoGrantPermission(runtime *common.RuntimeContext, ticket string) (map[string]interface{}, error) { status, err := getDriveImportStatus(runtime, ticket) if err != nil { return nil, err } - return map[string]interface{}{ + result := map[string]interface{}{ "scenario": "import", "ticket": status.Ticket, "type": status.DocType, @@ -146,7 +148,13 @@ func queryImportTask(runtime *common.RuntimeContext, ticket string) (map[string] "token": status.Token, "url": status.URL, "extra": status.Extra, - }, nil + } + if status.Ready() { + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, status.Token, status.DocType); grant != nil { + result["permission_grant"] = grant + } + } + return result, nil } // queryExportTask returns the export task status together with download metadata diff --git a/shortcuts/drive/drive_task_result_test.go b/shortcuts/drive/drive_task_result_test.go index f31ae818..b105f4df 100644 --- a/shortcuts/drive/drive_task_result_test.go +++ b/shortcuts/drive/drive_task_result_test.go @@ -156,6 +156,64 @@ func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) { if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) { t.Fatalf("stdout missing job_status_label: %s", stdout.String()) } + if bytes.Contains(stdout.Bytes(), []byte(`"permission_grant"`)) { + t.Fatalf("stdout should not include permission_grant before import is ready: %s", stdout.String()) + } +} + +func TestDriveTaskResultImportBotAutoGrantSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user")) + registerDriveBotTokenStub(reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/import_tasks/tk_import_ready", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "type": "sheet", + "job_status": 0, + "token": "sheet_imported", + "url": "https://example.feishu.cn/sheets/sheet_imported", + }, + }, + }, + }) + + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/sheet_imported/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } + reg.Register(permStub) + + err := mountAndRunDrive(t, DriveTaskResult, []string{ + "+task_result", + "--scenario", "import", + "--ticket", "tk_import_ready", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantGranted { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) + } + if grant["user_open_id"] != "ou_current_user" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user") + } + + body := decodeCapturedJSONBody(t, permStub) + if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", body) + } } func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) { diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index 891a254c..18d39bc9 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -40,7 +40,7 @@ var DriveUpload = common.Shortcut{ if fileName == "" { fileName = filepath.Base(filePath) } - return common.NewDryRunAPI(). + d := common.NewDryRunAPI(). Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)"). POST("/open-apis/drive/v1/files/upload_all"). Body(map[string]interface{}{ @@ -49,6 +49,10 @@ var DriveUpload = common.Shortcut{ "parent_node": folderToken, "file": "@" + filePath, }) + if runtime.IsBot() { + d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.") + } + return d }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { filePath := runtime.Str("file") @@ -85,11 +89,16 @@ var DriveUpload = common.Shortcut{ return err } - runtime.Out(map[string]interface{}{ + out := map[string]interface{}{ "file_token": fileToken, "file_name": fileName, "size": fileSize, - }, nil) + } + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil { + out["permission_grant"] = grant + } + + runtime.Out(out, nil) return nil }, } diff --git a/shortcuts/sheets/sheet_create.go b/shortcuts/sheets/sheet_create.go index 3461e7f7..69266a57 100644 --- a/shortcuts/sheets/sheet_create.go +++ b/shortcuts/sheets/sheet_create.go @@ -41,9 +41,13 @@ var SheetCreate = common.Shortcut{ return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - return common.NewDryRunAPI(). + d := common.NewDryRunAPI(). POST("/open-apis/sheets/v3/spreadsheets"). Body(map[string]interface{}{"title": runtime.Str("title")}) + if runtime.IsBot() { + d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.") + } + return d }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { title := runtime.Str("title") @@ -101,11 +105,16 @@ var SheetCreate = common.Shortcut{ } } - runtime.Out(map[string]interface{}{ + out := map[string]interface{}{ "spreadsheet_token": token, "title": title, "url": spreadsheet["url"], - }, nil) + } + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil { + out["permission_grant"] = grant + } + + runtime.Out(out, nil) return nil }, } diff --git a/shortcuts/sheets/sheet_create_test.go b/shortcuts/sheets/sheet_create_test.go new file mode 100644 index 00000000..11e43f37 --- /dev/null +++ b/shortcuts/sheets/sheet_create_test.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestSheetCreateBotAutoGrantSuccess(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": "https://example.feishu.cn/sheets/shtcn_new_sheet", + }, + }, + }, + }) + + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/shtcn_new_sheet/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } + reg.Register(permStub) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "bot", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantGranted { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) + } + if grant["user_open_id"] != "ou_current_user" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user") + } + if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." { + t.Fatalf("permission_grant.message = %#v", grant["message"]) + } + + var body map[string]interface{} + if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil { + t.Fatalf("failed to parse permission request body: %v", err) + } + if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", body) + } +} + +func TestSheetCreateUserSkipsPermissionGrantAugmentation(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": "https://example.feishu.cn/sheets/shtcn_new_sheet", + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode output: %#v", data) + } +} + +func sheetCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig { + t.Helper() + + replacer := strings.NewReplacer("/", "-", " ", "-") + suffix := replacer.Replace(strings.ToLower(t.Name())) + return &core.CliConfig{ + AppID: "test-sheet-create-" + suffix, + AppSecret: "secret-sheet-create-" + suffix, + Brand: core.BrandFeishu, + UserOpenId: userOpenID, + } +} + +func runSheetCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + + parent := &cobra.Command{Use: "sheets"} + SheetCreate.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func decodeSheetCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data in output envelope: %#v", envelope) + } + return data +} diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md index b26a162f..fca09839 100644 --- a/skills/lark-doc/references/lark-doc-create.md +++ b/skills/lark-doc/references/lark-doc-create.md @@ -31,26 +31,23 @@ lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown - **`doc_id`**(string):文档的唯一标识符(token),格式如 `doxcnXXXXXXXXXXXXXXXXXXX` - **`doc_url`**(string):文档的访问链接,可直接在浏览器中打开 - **`message`**(string):操作结果消息,如"文档创建成功" +- **`permission_grant`**(object,可选):仅 `--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限 > [!IMPORTANT] > 当文档创建在 `wiki_node` 或 `wiki_space` 下时,返回的 `doc_url` 可能是 `/wiki/...` 形式的知识库链接,而不是 `/docx/...` 形式的文档链接。 > 如果后续要调用 [`lark-doc-media-insert`](lark-doc-media-insert.md) 这类当前只支持 `doc_id` 或 `/docx/...` URL 自动提取的 skill,请优先使用返回值里的 `doc_id`,不要直接复用这个 `doc_url`。 > [!IMPORTANT] -> 如果文档是**以应用身份(bot)创建**的,agent 在创建成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该文档的 `full_access`(管理员)权限。推荐流程: -> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id` -> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该文档的 `full_access`(管理员)权限 +> 如果文档是**以应用身份(bot)创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后, CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。 > -> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。 +> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果: +> - `status = granted`:当前 CLI 用户已获得该文档的可管理权限 +> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限 +> - `status = failed`:文档已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文档 > -> 回复创建结果时,除 `doc_id` / `doc_url` 外,还必须明确告知用户授权结果: -> - 如果授权成功:直接说明当前 user 已获得该文档的管理员权限 -> - 如果本地没有可用的 user 身份:明确说明因此未完成授权 -> - 如果授权失败:明确说明文档已创建成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该文档 +> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。 > -> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该文档;如果希望后续改由自己管理,也可将文档 owner 转移给该用户。 -> -> **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 +> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 ## 参数 diff --git a/skills/lark-drive/references/lark-drive-import.md b/skills/lark-drive/references/lark-drive-import.md index ab176c60..eacb3b07 100644 --- a/skills/lark-drive/references/lark-drive-import.md +++ b/skills/lark-drive/references/lark-drive-import.md @@ -60,6 +60,22 @@ lark-cli drive +import --file ./README.md --type docx --dry-run > - `.xls` 文件**只能**导入为 `sheet` > - 例如:`.csv` 文件不能导入为 `docx`,`.md` 文件不能导入为 `sheet` +> [!IMPORTANT] +> 如果在线文档是**以应用身份(bot)导入创建**的,如 `lark-cli drive +import --as bot`,当某次结果**已经返回最终在线文档目标**后,CLI 会**尝试为当前 CLI 用户自动授予该资源的 `full_access`(可管理权限)**。 +> +> 这个自动授权有两种触发时机: +> - `drive +import` 的内置轮询窗口内已经完成,直接在 `+import` 中进行自动授权 +> - `drive +import` 先返回 `ready=false` / `timed_out=true`,之后你再执行 `lark-cli drive +task_result --scenario import --ticket `,当该查询第一次拿到最终在线文档目标时会自动授权 +> +> 只有在已经拿到最终在线文档目标的那次结果里,才会返回 `permission_grant` 字段,明确说明授权结果: +> - `status = granted`:当前 CLI 用户已获得该导入结果的可管理权限 +> - `status = skipped`:本地没有可用的当前用户 `open_id`,或当前结果还没有可授权目标,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限 +> - `status = failed`:导入已成功返回最终在线文档,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文档 +> +> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。 +> +> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 + ### 文件大小限制 除扩展名与目标类型匹配外,`drive +import` 还会在本地上传前校验格式级大小限制: @@ -83,6 +99,8 @@ lark-cli drive +import --file ./README.md --type docx --dry-run - `ready=false` - `timed_out=true` - `next_command`:可直接复制执行的后续查询命令,例如 `lark-cli drive +task_result --scenario import --ticket ` +- 若使用 `--as bot` 且内置轮询窗口内已经拿到最终在线文档,输出还会额外带上 `permission_grant`,用于说明是否已自动为当前 CLI 用户授予可管理权限。 +- 若使用 `--as bot` 但当前只返回 `ready=false`,此时还不会返回 `permission_grant`;应继续执行返回值里的 `next_command`,等 `drive +task_result --scenario import` 拿到最终文档后再触发自动授权。 - 如果文件扩展名不被支持,执行时将抛出验证错误。 ### 超时后的继续查询 @@ -93,6 +111,8 @@ lark-cli drive +import --file ./README.md --type docx --dry-run lark-cli drive +task_result --scenario import --ticket ``` +如果这里最终返回 `ready=true` 且使用的是 `--as bot`,结果还会额外带上 `permission_grant`,用于说明是否已自动为当前 CLI 用户授予可管理权限。 + > [!CAUTION] > `drive +import` 是**写入操作** —— 执行前必须确认用户意图。 diff --git a/skills/lark-drive/references/lark-drive-task-result.md b/skills/lark-drive/references/lark-drive-task-result.md index 4c42aaed..fa555620 100644 --- a/skills/lark-drive/references/lark-drive-task-result.md +++ b/skills/lark-drive/references/lark-drive-task-result.md @@ -5,6 +5,18 @@ 查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹等多种异步任务的结果查询,统一接口方便调用。 +> [!IMPORTANT] +> 对于 `import` 场景,如果使用 `--as bot` 且这次查询**已经拿到最终在线文档目标**(`ready=true` 且返回了最终 `token` / `url`),CLI 会**再次尝试为当前 CLI 用户自动授予该资源的 `full_access`(可管理权限)**。 +> +> 此时结果里会额外返回 `permission_grant` 字段,明确说明授权结果: +> - `status = granted`:当前 CLI 用户已获得该导入结果的可管理权限 +> - `status = skipped`:本地没有可用的当前用户 `open_id`,或最终结果缺少可授权的在线文档目标,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限 +> - `status = failed`:导入结果已就绪,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文档 +> +> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。 +> +> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 + ## 命令 ```bash @@ -58,7 +70,14 @@ lark-cli drive +task_result \ "job_error_msg": "success", "token": "", "url": "https://example.feishu.cn/sheets/", - "extra": ["2000"] + "extra": ["2000"], + "permission_grant": { + "status": "granted", + "perm": "full_access", + "member_type": "openid", + "user_open_id": "", + "message": "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." + } } ``` @@ -69,6 +88,7 @@ lark-cli drive +task_result \ - `job_status_label`: 便于阅读的状态标签,例如 `success` / `processing` - `token`: 导入后的文档 token - `url`: 导入后的文档链接 +- `permission_grant`: 仅 `--as bot` 且这次查询已经拿到最终在线文档目标时返回,用于说明是否已自动为当前 CLI 用户授予可管理权限;如果当前仍是 `ready=false`,则不会返回这个字段 ### Export 场景返回 @@ -127,6 +147,7 @@ lark-cli drive +import --file ./data.xlsx --type sheet # 2. 轮询导入结果 lark-cli drive +task_result --scenario import --ticket +# 如果这里返回 ready=true 且使用 --as bot,结果还会包含 permission_grant ``` ### 配合 +move 使用 @@ -164,6 +185,9 @@ lark-cli drive +export-download --file-token | export | `drive:drive.metadata:readonly` | | task_check | `drive:drive.metadata:readonly` | +> [!NOTE] +> `import` 场景在 `--as bot` 且任务最终就绪时,还可能额外尝试一次协作者授权;如果 `permission_grant.status = failed`,请根据失败信息检查应用是否具备相应的文档协作者授权能力。 + ## 参考 - [lark-drive](../SKILL.md) -- 云空间全部命令 diff --git a/skills/lark-drive/references/lark-drive-upload.md b/skills/lark-drive/references/lark-drive-upload.md index 8a625b01..a509b32e 100644 --- a/skills/lark-drive/references/lark-drive-upload.md +++ b/skills/lark-drive/references/lark-drive-upload.md @@ -38,20 +38,16 @@ lark-cli schema drive.files.upload_prepare ``` > [!IMPORTANT] -> 如果文件是**以应用身份(bot)上传**的,agent 在上传成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该文件的 `full_access`(管理员)权限。推荐流程: -> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id` -> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该文件的 `full_access`(管理员)权限 +> 如果文件是**以应用身份(bot)上传**的,如 `lark-cli drive +upload --as bot` 在上传成功后,CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。 > -> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。 +> 以应用身份上传时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果: +> - `status = granted`:当前 CLI 用户已获得该文件的可管理权限 +> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限 +> - `status = failed`:文件已上传成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文件 > -> 回复上传结果时,除 `file_token` 外,还必须明确告知用户授权结果: -> - 如果授权成功:直接说明当前 user 已获得该文件的管理员权限 -> - 如果本地没有可用的 user 身份:明确说明因此未完成授权 -> - 如果授权失败:明确说明文件已上传成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该文件 +> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。 > -> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该文件;如果希望后续改由自己管理,也可将文件 owner 转移给该用户。 -> -> **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 +> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 参数(预上传 `--data` JSON body): diff --git a/skills/lark-sheets/references/lark-sheets-create.md b/skills/lark-sheets/references/lark-sheets-create.md index 80d7a46f..cba15ff1 100644 --- a/skills/lark-sheets/references/lark-sheets-create.md +++ b/skills/lark-sheets/references/lark-sheets-create.md @@ -14,20 +14,16 @@ > 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 > [!IMPORTANT] -> 如果表格是**以应用身份(bot)创建**的,agent 在创建成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该表格的 `full_access`(管理员)权限。推荐流程: -> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id` -> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该表格的 `full_access`(管理员)权限 +> 如果表格是**以应用身份(bot)创建**的,如 `lark-cli sheets +create --as bot` 在表格创建成功后,CLI 会**尝试为当前 CLI 用户自动授予该表格的 `full_access`(可管理权限)**。 > -> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。 +> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果: +> - `status = granted`:当前 CLI 用户已获得该表格的可管理权限 +> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限 +> - `status = failed`:表格已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该表格 > -> 回复创建结果时,除 `spreadsheet_token` / `url` 外,还必须明确告知用户授权结果: -> - 如果授权成功:直接说明当前 user 已获得该表格的管理员权限 -> - 如果本地没有可用的 user 身份:明确说明因此未完成授权 -> - 如果授权失败:明确说明表格已创建成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该表格 +> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。 > -> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该表格;如果希望后续改由自己管理,也可将表格 owner 转移给该用户。 -> -> **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 +> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 ## 命令 @@ -64,6 +60,7 @@ JSON,包含: - `spreadsheet_token` - `title` - `url` +- `permission_grant`(仅 `--as bot` 时返回) ## 参考