feat(base): auto grant current user for bot create and copy (#497)

* feat(base): auto grant current user for bot create and copy

* fix(base): declare auto-grant permission scope

* Apply suggestion from @kongenpei

Co-authored-by: kongenpei <kongenpei.jojo@bytedance.com>

* Apply suggestion from @kongenpei

Co-authored-by: kongenpei <kongenpei.jojo@bytedance.com>

* style(base): format auth-specific scope declarations

* fix(base): use bitable permission target for auto-grant

---------

Co-authored-by: kongenpei <kongenpei.jojo@bytedance.com>
This commit is contained in:
zgz2048
2026-04-17 14:30:47 +08:00
committed by GitHub
parent 0d50616e77
commit 94bba91224
7 changed files with 352 additions and 67 deletions

View File

@@ -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),

View File

@@ -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},

View File

@@ -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) {

View File

@@ -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 ""
}

View File

@@ -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. 常见错误与恢复

View File

@@ -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 转给自己,必须单独确认。

View File

@@ -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 转给自己,必须单独确认。