diff --git a/shortcuts/base/base_copy.go b/shortcuts/base/base_copy.go index ff33f0a1d..cb12e0e01 100644 --- a/shortcuts/base/base_copy.go +++ b/shortcuts/base/base_copy.go @@ -14,7 +14,8 @@ var BaseBaseCopy = common.Shortcut{ Command: "+base-copy", Description: "Copy a base resource", Risk: "write", - Scopes: []string{"base:app:copy"}, + UserScopes: []string{"base:app:copy"}, + BotScopes: []string{"base:app:copy", "docs:permission.member:create"}, AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), diff --git a/shortcuts/base/base_create.go b/shortcuts/base/base_create.go index b5f69a1ee..af4286638 100644 --- a/shortcuts/base/base_create.go +++ b/shortcuts/base/base_create.go @@ -14,7 +14,8 @@ var BaseBaseCreate = common.Shortcut{ Command: "+base-create", Description: "Create a new base resource", Risk: "write", - Scopes: []string{"base:app:create"}, + UserScopes: []string{"base:app:create"}, + BotScopes: []string{"base:app:create", "docs:permission.member:create"}, AuthTypes: authTypes(), Flags: []common.Flag{ {Name: "name", Desc: "base name", Required: true}, diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 36654c2b5..35b8b1e13 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -6,6 +6,7 @@ package base import ( "bytes" "context" + "encoding/json" "os" "path/filepath" "strings" @@ -19,12 +20,16 @@ import ( ) func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) { + return newExecuteFactoryWithUserOpenID(t, "ou_testuser") +} + +func newExecuteFactoryWithUserOpenID(t *testing.T, userOpenID string) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) { t.Helper() config := &core.CliConfig{ AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"), AppSecret: "test-secret", Brand: core.BrandFeishu, - UserOpenId: "ou_testuser", + UserOpenId: userOpenID, } factory, stdout, _, reg := cmdutil.TestFactory(t, config) return factory, stdout, reg @@ -48,7 +53,14 @@ func withBaseWorkingDir(t *testing.T, dir string) { func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error { t.Helper() - shortcut.AuthTypes = []string{"bot"} + return runShortcutWithAuthTypes(t, shortcut, []string{"bot"}, args, factory, stdout) +} + +func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes []string, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + if authTypes != nil { + shortcut.AuthTypes = authTypes + } parent := &cobra.Command{Use: "base"} shortcut.Mount(parent, factory) parent.SetArgs(args) @@ -60,6 +72,14 @@ func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory func TestBaseWorkspaceExecuteCreate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/base/v3/bases", @@ -68,11 +88,32 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) { "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, }, }) + reg.Register(permStub) if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) { - t.Fatalf("stdout=%s", got) + data := decodeBaseEnvelope(t, stdout) + if data["created"] != true { + t.Fatalf("created = %#v, want true", data["created"]) + } + base, _ := data["base"].(map[string]interface{}) + if got := common.GetString(base, "app_token"); got != "app_x" { + t.Fatalf("base.app_token = %q, want %q", got, "app_x") + } + 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_testuser" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser") + } + if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." { + t.Fatalf("permission_grant.message = %#v", grant["message"]) + } + + body := decodeCapturedJSONBody(t, permStub) + if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", body) } } @@ -97,6 +138,14 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) { t.Run("copy", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/base/v3/bases/app_src/copy", @@ -105,14 +154,243 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) { "data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"}, }, }) + reg.Register(permStub) args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"} if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) { + data := decodeBaseEnvelope(t, stdout) + if data["copied"] != true { + t.Fatalf("copied = %#v, want true", data["copied"]) + } + base, _ := data["base"].(map[string]interface{}) + if got := common.GetString(base, "base_token"); got != "app_new" { + t.Fatalf("base.base_token = %q, want %q", got, "app_new") + } + 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_testuser" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser") + } + + body := decodeCapturedJSONBody(t, permStub) + if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", body) + } + }) +} + +func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) { + factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "") + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + + if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + data := decodeBaseEnvelope(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 TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable", + Body: map[string]interface{}{ + "code": 230001, + "msg": "no permission", + }, + }) + + if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil { + t.Fatalf("Base creation should still succeed when auto-grant fails, got: %v", err) + } + + data := decodeBaseEnvelope(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"]) + } +} + +func TestBaseWorkspaceExecuteCreateUserSkipsPermissionGrantAugmentation(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + + if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode output: %#v", data) + } +} + +func TestBaseWorkspaceExecuteCopyBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) { + factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "") + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_src/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"}, + }, + }) + + if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + data := decodeBaseEnvelope(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) + } +} + +func TestBaseWorkspaceExecuteCopyBotAutoGrantFailureDoesNotFailCopy(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_src/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_new", "name": "Copied Base"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable", + Body: map[string]interface{}{ + "code": 230001, + "msg": "no permission", + }, + }) + + if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil { + t.Fatalf("Base copy should still succeed when auto-grant fails, got: %v", err) + } + + data := decodeBaseEnvelope(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) + } +} + +func TestBaseWorkspaceExecuteCopyUserSkipsPermissionGrantAugmentation(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_src/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"}, + }, + }) + + if err := runShortcutWithAuthTypes(t, BaseBaseCopy, authTypes(), []string{"+base-copy", "--base-token", "app_src", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode output: %#v", data) + } +} + +func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) { + t.Run("create bot", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") { t.Fatalf("stdout=%s", got) } }) + + t.Run("copy bot", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("create user", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") { + t.Fatalf("stdout=%s", got) + } + }) +} + +func decodeBaseEnvelope(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 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 } func TestBaseHistoryExecute(t *testing.T) { diff --git a/shortcuts/base/base_ops.go b/shortcuts/base/base_ops.go index 8a70cba73..20dbe2335 100644 --- a/shortcuts/base/base_ops.go +++ b/shortcuts/base/base_ops.go @@ -17,36 +17,24 @@ func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr } func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body := map[string]interface{}{} - if name := strings.TrimSpace(runtime.Str("name")); name != "" { - body["name"] = name - } - if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { - body["folder_token"] = folderToken - } - if runtime.Bool("without-content") { - body["without_content"] = true - } - if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { - body["time_zone"] = timeZone - } - return common.NewDryRunAPI(). + d := common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/copy"). - Body(body). + Body(buildBaseCopyBody(runtime)). Set("base_token", runtime.Str("base-token")) + if runtime.IsBot() { + d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.") + } + return d } func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body := map[string]interface{}{"name": runtime.Str("name")} - if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { - body["folder_token"] = folderToken - } - if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { - body["time_zone"] = timeZone - } - return common.NewDryRunAPI(). + d := common.NewDryRunAPI(). POST("/open-apis/base/v3/bases"). - Body(body) + Body(buildBaseCreateBody(runtime)) + if runtime.IsBot() { + d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.") + } + return d } func executeBaseGet(runtime *common.RuntimeContext) error { @@ -59,6 +47,28 @@ func executeBaseGet(runtime *common.RuntimeContext) error { } func executeBaseCopy(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, buildBaseCopyBody(runtime)) + if err != nil { + return err + } + out := map[string]interface{}{"base": data, "copied": true} + augmentBasePermissionGrant(runtime, out, data) + runtime.Out(out, nil) + return nil +} + +func executeBaseCreate(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, buildBaseCreateBody(runtime)) + if err != nil { + return err + } + out := map[string]interface{}{"base": data, "created": true} + augmentBasePermissionGrant(runtime, out, data) + runtime.Out(out, nil) + return nil +} + +func buildBaseCopyBody(runtime *common.RuntimeContext) map[string]interface{} { body := map[string]interface{}{} if name := strings.TrimSpace(runtime.Str("name")); name != "" { body["name"] = name @@ -72,15 +82,10 @@ func executeBaseCopy(runtime *common.RuntimeContext) error { if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { body["time_zone"] = timeZone } - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, body) - if err != nil { - return err - } - runtime.Out(map[string]interface{}{"base": data, "copied": true}, nil) - return nil + return body } -func executeBaseCreate(runtime *common.RuntimeContext) error { +func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{} { body := map[string]interface{}{"name": runtime.Str("name")} if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { body["folder_token"] = folderToken @@ -88,10 +93,20 @@ func executeBaseCreate(runtime *common.RuntimeContext) error { if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { body["time_zone"] = timeZone } - data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, body) - if err != nil { - return err - } - runtime.Out(map[string]interface{}{"base": data, "created": true}, nil) - return nil + return body +} + +func augmentBasePermissionGrant(runtime *common.RuntimeContext, out, base map[string]interface{}) { + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, extractBasePermissionToken(base), "bitable"); grant != nil { + out["permission_grant"] = grant + } +} + +func extractBasePermissionToken(base map[string]interface{}) string { + for _, key := range []string{"base_token", "app_token"} { + if token := strings.TrimSpace(common.GetString(base, key)); token != "" { + return token + } + } + return "" } diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 7ed0c4d87..5d6ecad92 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -316,7 +316,7 @@ lark-cli auth login --domain base - 删除记录 / 字段 / 表时,如果用户已经明确说要删除,且目标明确,`+record-delete / +field-delete / +table-delete` 可直接执行,并带 `--yes`。 - 删除目标仍有歧义时,先用 `+record-get / +field-get / +table-get` 或相应 list 命令确认。 - `+base-create / +base-copy` 成功后,回复中必须主动返回新 Base 的标识信息;若结果带可访问链接,也应一并返回。 -- 若 Base 由 bot 身份创建且当前 CLI 存在可用 user 身份,优先继续补授当前 user 为 `full_access`;owner 转移必须单独确认,禁止擅自执行。 +- 若 Base 由 bot 身份创建或复制,shortcut 会自动尝试为当前 CLI 用户补授 `full_access`,并在输出中返回 `permission_grant`;agent 不需要再手动编排单独授权。owner 转移必须单独确认,禁止擅自执行。 ## 5. 常见错误与恢复 diff --git a/skills/lark-base/references/lark-base-base-copy.md b/skills/lark-base/references/lark-base-base-copy.md index ee1d6a51d..c4d0940d8 100644 --- a/skills/lark-base/references/lark-base-base-copy.md +++ b/skills/lark-base/references/lark-base-base-copy.md @@ -47,19 +47,14 @@ POST /open-apis/base/v3/bases/:base_token/copy - 如果本次返回没有 `url`,至少返回新 Base 的名称和 token > [!IMPORTANT] -> 如果 Base 是**以应用身份(bot)复制**出来的,agent 在复制成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该 Base 的 `full_access`(管理员)权限。 -> 推荐流程: -> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id` -> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该 Base 的 `full_access`(管理员)权限 +> 如果 Base 是**以应用身份(bot)复制**出来的,shortcut 会在复制成功后自动尝试为当前 CLI 用户添加该 Base 的 `full_access`(管理员)权限,并在输出中附带 `permission_grant` 字段。 > -> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。 +> `permission_grant.status` 语义如下: +> - `granted`:当前 CLI 用户已获得该 Base 的管理员权限 +> - `skipped`:Base 已复制成功,但没有可授权的当前 CLI 用户,或复制结果缺少可授权 token +> - `failed`:Base 已复制成功,但自动授权失败;结果中会包含失败原因,用户可稍后重试授权,或继续使用应用身份(bot)处理该 Base > -> 回复复制结果时,除 `base token` 和可访问链接外,还必须明确告知用户授权结果: -> - 如果授权成功:直接说明当前 user 已获得该 Base 的管理员权限 -> - 如果本地没有可用的 user 身份:明确说明因此未完成授权 -> - 如果授权失败:明确说明 Base 已复制成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该 Base -> -> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该 Base;如果希望后续改由自己管理,也可将 Base owner 转移给该用户。 +> 回复复制结果时,除 `base token` 和可访问链接外,还必须明确告知用户 `permission_grant` 的结果。 > > **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 diff --git a/skills/lark-base/references/lark-base-base-create.md b/skills/lark-base/references/lark-base-base-create.md index 6c910ebb6..506fff41f 100644 --- a/skills/lark-base/references/lark-base-base-create.md +++ b/skills/lark-base/references/lark-base-base-create.md @@ -42,19 +42,14 @@ POST /open-apis/base/v3/bases - 如果本次返回没有 `url`,至少返回新 Base 的名称和 token > [!IMPORTANT] -> 如果 Base 是**以应用身份(bot)创建**的,agent 在创建成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该 Base 的 `full_access`(管理员)权限。 -> 推荐流程: -> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id` -> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该 Base 的 `full_access`(管理员)权限 +> 如果 Base 是**以应用身份(bot)创建**的,shortcut 会在创建成功后自动尝试为当前 CLI 用户添加该 Base 的 `full_access`(管理员)权限,并在输出中附带 `permission_grant` 字段。 > -> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。 +> `permission_grant.status` 语义如下: +> - `granted`:当前 CLI 用户已获得该 Base 的管理员权限 +> - `skipped`:Base 已创建成功,但没有可授权的当前 CLI 用户,或创建结果缺少可授权 token +> - `failed`:Base 已创建成功,但自动授权失败;结果中会包含失败原因,用户可稍后重试授权,或继续使用应用身份(bot)处理该 Base > -> 回复创建结果时,除 `base token` 和可访问链接外,还必须明确告知用户授权结果: -> - 如果授权成功:直接说明当前 user 已获得该 Base 的管理员权限 -> - 如果本地没有可用的 user 身份:明确说明因此未完成授权 -> - 如果授权失败:明确说明 Base 已创建成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该 Base -> -> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该 Base;如果希望后续改由自己管理,也可将 Base owner 转移给该用户。 +> 回复创建结果时,除 `base token` 和可访问链接外,还必须明确告知用户 `permission_grant` 的结果。 > > **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。