diff --git a/internal/httpmock/registry.go b/internal/httpmock/registry.go index ef32e45dc3..156aef299a 100644 --- a/internal/httpmock/registry.go +++ b/internal/httpmock/registry.go @@ -38,6 +38,10 @@ type Stub struct { // matches after the first hit. Each match appends to CapturedBodies. Reusable bool + // Optional (optional): when true, Verify does not require this stub to be + // matched. Useful for negative assertions via OnMatch. + Optional bool + // CapturedHeaders records the request headers of the matched request. // Populated after RoundTrip matches this stub. CapturedHeaders http.Header @@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) { if s.matched { continue } + if s.Optional { + continue + } // Reusable stubs never set s.matched; treat any captured hit as a match. if s.Reusable && len(s.CapturedBodies) > 0 { continue diff --git a/shortcuts/base/base_resolve.go b/shortcuts/base/base_resolve.go index 3cc6b93e92..31de0dbec6 100644 --- a/shortcuts/base/base_resolve.go +++ b/shortcuts/base/base_resolve.go @@ -29,6 +29,7 @@ var BaseURLResolve = common.Shortcut{ Risk: "read", Scopes: []string{}, ConditionalScopes: []string{ + "base:block:read", "base:field:read", "base:record:read", "wiki:node:retrieve", @@ -40,7 +41,7 @@ var BaseURLResolve = common.Shortcut{ {Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"}, }, Tips: []string{ - `Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/?table=&view="`, + `Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/?table=&view="`, "Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -57,10 +58,34 @@ var BaseURLResolve = common.Shortcut{ return common.NewDryRunAPI().Set("error", err.Error()) } switch classifyBaseURL(parsed) { + case "base_url": + baseToken := firstPathSegmentAfter(parsed.Path, "/base/") + if selectedBlockID := strings.TrimSpace(parsed.Query().Get("table")); selectedBlockID != "" { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/blocks/list"). + Body(map[string]interface{}{}). + Set("base_token", baseToken). + Set("selected_block_id", selectedBlockID) + } + return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local") case "wiki_url": - return common.NewDryRunAPI(). - GET("/open-apis/wiki/v2/spaces/get_node"). + dry := common.NewDryRunAPI() + selectedBlockID := strings.TrimSpace(parsed.Query().Get("table")) + if selectedBlockID == "" { + return dry. + GET("/open-apis/wiki/v2/spaces/get_node"). + Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")}) + } + dry.Desc("2-step: resolve the Wiki node to a Base, then identify the selected Base block") + dry.GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve the Wiki node to its underlying Base"). Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")}) + dry.POST("/open-apis/base/v3/bases/:base_token/blocks/list"). + Desc("[2] List Base blocks and match selected_block_id"). + Body(map[string]interface{}{}) + return dry. + Set("base_token", ""). + Set("selected_block_id", selectedBlockID) case "record_share_url": return common.NewDryRunAPI(). GET("/open-apis/base/v3/record_share/:record_share_token/meta"). @@ -170,7 +195,7 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error { switch classifyBaseURL(parsed) { case "base_url": out := resolveBaseURL(parsed) - enrichBaseResolveHint(runtime, out) + enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed)) runtime.OutFormat(out, nil, nil) return nil case "wiki_url": @@ -178,6 +203,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error { if err != nil { return err } + selection := resolveBaseURLSelection(parsed) + applyBaseURLSelection(out, selection) + enrichBaseResolveHint(runtime, out, selection) runtime.OutFormat(out, nil, nil) return nil case "record_share_url": @@ -251,22 +279,48 @@ func classifyBaseURL(u *url.URL) string { } func resolveBaseURL(u *url.URL) map[string]interface{} { - query := u.Query() out := map[string]interface{}{ "input_type": "base_url", "resource_type": "bitable", "base_token": firstPathSegmentAfter(u.Path, "/base/"), } - if tableID := strings.TrimSpace(query.Get("table")); tableID != "" { - out["table_id"] = tableID + applyBaseURLSelection(out, resolveBaseURLSelection(u)) + return out +} + +type baseURLSelection struct { + blockID string + viewID string + recordID string +} + +func resolveBaseURLSelection(u *url.URL) baseURLSelection { + query := u.Query() + return baseURLSelection{ + blockID: strings.TrimSpace(query.Get("table")), + viewID: strings.TrimSpace(query.Get("view")), + recordID: strings.TrimSpace(query.Get("record")), } - if viewID := strings.TrimSpace(query.Get("view")); viewID != "" { - out["view_id"] = viewID +} + +func applyBaseURLSelection(out map[string]interface{}, selection baseURLSelection) { + if selection.blockID != "" { + // The Base web UI historically uses the query key "table" for the + // currently selected top-level block. Its value can identify a table, + // dashboard, workflow, or another block type. Keep it neutral until the + // block directory confirms the resource type. + out["block_id"] = selection.blockID + out["selection_source"] = "url_query" + } +} + +func applyResolvedTableSelection(out map[string]interface{}, selection baseURLSelection) { + if selection.viewID != "" { + out["view_id"] = selection.viewID } - if recordID := strings.TrimSpace(query.Get("record")); recordID != "" { - out["record_id"] = recordID + if selection.recordID != "" { + out["record_id"] = selection.recordID } - return out } func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) { @@ -368,13 +422,89 @@ func executeBaseTitleResolve(runtime *common.RuntimeContext) error { } } -func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) { +func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) { baseToken := strings.TrimSpace(common.GetString(out, "base_token")) - tableID := strings.TrimSpace(common.GetString(out, "table_id")) - if baseToken == "" || tableID == "" { + selectedBlockID := strings.TrimSpace(common.GetString(out, "block_id")) + if baseToken == "" || selectedBlockID == "" { out["hint"] = resolveHint("", nil) return } + + if block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID); err == nil && found { + out["block_type"] = block.Type + if block.Name != "" { + out["block_name"] = block.Name + } + switch block.Type { + case "table": + applyResolvedTableSelection(out, selection) + enrichResolvedTable(runtime, out, baseToken, selectedBlockID) + case "dashboard": + out["dashboard_id"] = selectedBlockID + out["hint"] = map[string]interface{}{ + "next_step": "this dashboard is only the block currently selected by the URL; if the user names a different dashboard than block_name, use +dashboard-list and match that name first, otherwise use +dashboard-get to inspect this dashboard", + } + case "workflow": + out["workflow_id"] = selectedBlockID + out["hint"] = map[string]interface{}{ + "next_step": "use +workflow-get to inspect the resolved workflow", + } + case "folder": + out["hint"] = map[string]interface{}{ + "next_step": fmt.Sprintf("use +base-block-list --base-token %s --parent-id %s to list this folder's direct children", baseToken, selectedBlockID), + } + case "docx": + if block.DocxToken != "" { + out["docx_token"] = block.DocxToken + out["hint"] = map[string]interface{}{ + "next_step": fmt.Sprintf("use docs +fetch --doc %s to read this document", block.DocxToken), + } + } else { + out["hint"] = map[string]interface{}{ + "next_step": "use +base-block-list --type docx and match block_id to retrieve this document's docx_token", + } + } + default: + out["hint"] = resolveUnknownBlockHint() + } + return + } + + out["hint"] = resolveUnknownBlockHint() +} + +type resolvedBaseBlock struct { + ID string + Type string + Name string + DocxToken string +} + +func resolveSelectedBaseBlock(runtime *common.RuntimeContext, baseToken, selectedBlockID string) (resolvedBaseBlock, bool, error) { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "blocks", "list"), nil, map[string]interface{}{}) + if err != nil { + return resolvedBaseBlock{}, false, err + } + for _, item := range common.GetSlice(data, "blocks") { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + block := resolvedBaseBlock{ + ID: strings.TrimSpace(common.GetString(row, "id")), + Type: strings.TrimSpace(common.GetString(row, "type")), + Name: strings.TrimSpace(common.GetString(row, "name")), + DocxToken: strings.TrimSpace(common.GetString(row, "docx_token")), + } + if block.ID == selectedBlockID { + return block, true, nil + } + } + return resolvedBaseBlock{}, false, nil +} + +func enrichResolvedTable(runtime *common.RuntimeContext, out map[string]interface{}, baseToken, tableID string) { + out["table_id"] = tableID fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100) if err != nil { out["hint"] = resolveHint(tableID, nil) @@ -383,6 +513,12 @@ func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interf out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}}) } +func resolveUnknownBlockHint() map[string]interface{} { + return map[string]interface{}{ + "next_step": "use +base-block-list and match block_id to determine whether this is a table, dashboard, workflow, folder, or docx block", + } +} + func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) { baseToken := strings.TrimSpace(common.GetString(out, "base_token")) tableID := strings.TrimSpace(common.GetString(out, "table_id")) diff --git a/shortcuts/base/base_resolve_test.go b/shortcuts/base/base_resolve_test.go index 8b00fc5605..c1579e9c2a 100644 --- a/shortcuts/base/base_resolve_test.go +++ b/shortcuts/base/base_resolve_test.go @@ -4,6 +4,7 @@ package base import ( + "net/http" "strings" "testing" @@ -17,6 +18,9 @@ import ( func TestBaseURLResolveBaseURL(t *testing.T) { t.Run("with coordinates", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) + reg.Register(baseBlockListResolveStub("bas123", + map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"}, + )) reg.Register(fieldListStub("bas123", "tbl123")) err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ "+url-resolve", @@ -31,7 +35,7 @@ func TestBaseURLResolveBaseURL(t *testing.T) { if data["input_type"] != "base_url" || data["base_token"] != "bas123" { t.Fatalf("unexpected output: %#v", data) } - if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" { + if data["block_id"] != "tbl123" || data["selection_source"] != "url_query" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" { t.Fatalf("missing Base coordinates: %#v", data) } hint, _ := data["hint"].(map[string]interface{}) @@ -62,45 +66,213 @@ func TestBaseURLResolveBaseURL(t *testing.T) { } }) - t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) { + t.Run("unconfirmed selected block stays neutral", func(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ - "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user", + "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale&record=rec_stale", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("err=%v", err) } data := decodeBaseEnvelope(t, stdout) - if data["base_token"] != "bas123" || data["table_id"] != "tbl123" { + if data["base_token"] != "bas123" || data["block_id"] != "tbl123" { t.Fatalf("unexpected output: %#v", data) } + if _, ok := data["table_id"]; ok { + t.Fatalf("unconfirmed block must not be reported as a table: %#v", data) + } + if _, ok := data["view_id"]; ok { + t.Fatalf("unconfirmed block must not expose table-only view_id: %#v", data) + } + if _, ok := data["record_id"]; ok { + t.Fatalf("unconfirmed block must not expose table-only record_id: %#v", data) + } hint, _ := data["hint"].(map[string]interface{}) - if hint["next_step"] != nextStepRecordList { + if !strings.Contains(hint["next_step"].(string), "+base-block-list") { t.Fatalf("unexpected hint: %#v", hint) } if _, ok := hint["fields"]; ok { t.Fatalf("fields should be omitted when enrichment fails: %#v", hint) } }) + + t.Run("field endpoint does not confirm untyped block", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(baseBlockListResolveStub("bas123", + map[string]interface{}{"id": "tbl_other", "type": "table", "name": "Other"}, + )) + fieldStub := fieldListStub("bas123", "tbl123") + fieldStub.Optional = true + fieldStub.OnMatch = func(_ *http.Request) { + t.Fatalf("field endpoint must not be used to infer selected block type") + } + reg.Register(fieldStub) + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["block_id"] != "tbl123" { + t.Fatalf("unexpected block coordinates: %#v", data) + } + if _, ok := data["block_type"]; ok { + t.Fatalf("field endpoint must not confirm block type without block directory: %#v", data) + } + if _, ok := data["table_id"]; ok { + t.Fatalf("field endpoint must not promote an untyped block to table_id: %#v", data) + } + if _, ok := data["view_id"]; ok { + t.Fatalf("untyped block must not expose table-only view_id: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + if _, ok := hint["fields"]; ok { + t.Fatalf("fields should be omitted when block type is unconfirmed: %#v", hint) + } + if !strings.Contains(hint["next_step"].(string), "+base-block-list") { + t.Fatalf("unexpected hint: %#v", hint) + } + }) + + t.Run("dashboard selected through table query key", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(baseBlockListResolveStub("bas123", + map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"}, + )) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_dashboard&view=vew_stale&record=rec_stale", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["block_id"] != "blk_dashboard" || data["selection_source"] != "url_query" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" || data["block_name"] != "Sales" { + t.Fatalf("unexpected dashboard coordinates: %#v", data) + } + if _, ok := data["table_id"]; ok { + t.Fatalf("dashboard must not be reported as table_id: %#v", data) + } + if _, ok := data["view_id"]; ok { + t.Fatalf("dashboard must not expose table-only view_id: %#v", data) + } + if _, ok := data["record_id"]; ok { + t.Fatalf("dashboard must not expose table-only record_id: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + nextStep := hint["next_step"].(string) + if !strings.Contains(nextStep, "+dashboard-get") || !strings.Contains(nextStep, "+dashboard-list") || !strings.Contains(nextStep, "different dashboard than block_name") { + t.Fatalf("unexpected dashboard hint: %#v", hint) + } + }) + + t.Run("workflow selected through table query key", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(baseBlockListResolveStub("bas123", + map[string]interface{}{"id": "wkf_notify", "type": "workflow", "name": "Notify"}, + )) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=wkf_notify&view=vew_stale&record=rec_stale", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["block_id"] != "wkf_notify" || data["block_type"] != "workflow" || data["workflow_id"] != "wkf_notify" { + t.Fatalf("unexpected workflow coordinates: %#v", data) + } + if _, ok := data["table_id"]; ok { + t.Fatalf("workflow must not be reported as table_id: %#v", data) + } + if _, ok := data["view_id"]; ok { + t.Fatalf("workflow must not expose table-only view_id: %#v", data) + } + if _, ok := data["record_id"]; ok { + t.Fatalf("workflow must not expose table-only record_id: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + if !strings.Contains(hint["next_step"].(string), "+workflow-get") { + t.Fatalf("unexpected workflow hint: %#v", hint) + } + }) + + t.Run("folder selected through table query key", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(baseBlockListResolveStub("bas123", + map[string]interface{}{"id": "bfl_projects", "type": "folder", "name": "Projects"}, + )) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=bfl_projects&view=vew_stale&record=rec_stale", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["block_id"] != "bfl_projects" || data["block_type"] != "folder" || data["block_name"] != "Projects" { + t.Fatalf("unexpected folder coordinates: %#v", data) + } + if _, ok := data["table_id"]; ok { + t.Fatalf("folder must not be reported as table_id: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + nextStep := hint["next_step"].(string) + if !strings.Contains(nextStep, "+base-block-list --base-token bas123 --parent-id bfl_projects") || strings.Contains(nextStep, "determine whether") { + t.Fatalf("unexpected folder hint: %#v", hint) + } + }) + + t.Run("docx selected through table query key", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(baseBlockListResolveStub("bas123", + map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec", "docx_token": "docx123"}, + )) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_doc&view=vew_stale&record=rec_stale", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["block_id"] != "blk_doc" || data["block_type"] != "docx" || data["block_name"] != "Spec" || data["docx_token"] != "docx123" { + t.Fatalf("unexpected docx coordinates: %#v", data) + } + if _, ok := data["table_id"]; ok { + t.Fatalf("docx must not be reported as table_id: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + nextStep := hint["next_step"].(string) + if !strings.Contains(nextStep, "docs +fetch --doc docx123") || strings.Contains(nextStep, "determine whether") { + t.Fatalf("unexpected docx hint: %#v", hint) + } + }) +} + +func baseBlockListResolveStub(baseToken string, blocks ...map[string]interface{}) *httpmock.Stub { + items := make([]interface{}, 0, len(blocks)) + for _, block := range blocks { + items = append(items, block) + } + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/" + baseToken + "/blocks/list", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "blocks": items, + "total": len(items), + }, + }, + } } func TestBaseURLResolveWikiURL(t *testing.T) { t.Run("bitable", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "node": map[string]interface{}{ - "obj_type": "bitable", - "obj_token": "bas123", - "title": "Demo Base", - }, - }, - }, - }) + reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base")) err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ "+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user", @@ -114,6 +286,57 @@ func TestBaseURLResolveWikiURL(t *testing.T) { } }) + t.Run("bitable with table coordinates", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base")) + reg.Register(baseBlockListResolveStub("bas123", + map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"}, + )) + reg.Register(fieldListStub("bas123", "tbl123")) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", + "--url", "https://example.larkoffice.com/wiki/wik123?table=tbl123&view=vew123&record=rec123", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["block_id"] != "tbl123" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" { + t.Fatalf("unexpected Wiki Base table coordinates: %#v", data) + } + }) + + t.Run("bitable with dashboard selection", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base")) + reg.Register(baseBlockListResolveStub("bas123", + map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"}, + )) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", + "--url", "https://example.larkoffice.com/wiki/wik123?table=blk_dashboard&view=vew_stale&record=rec_stale", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if data["input_type"] != "wiki_url" || data["block_id"] != "blk_dashboard" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" { + t.Fatalf("unexpected Wiki Base dashboard coordinates: %#v", data) + } + if _, ok := data["view_id"]; ok { + t.Fatalf("dashboard must not expose table-only view_id: %#v", data) + } + if _, ok := data["record_id"]; ok { + t.Fatalf("dashboard must not expose table-only record_id: %#v", data) + } + }) + t.Run("non bitable", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -136,6 +359,23 @@ func TestBaseURLResolveWikiURL(t *testing.T) { }) } +func wikiBaseNodeStub(wikiToken, baseToken, title string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node?token=" + wikiToken, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "bitable", + "obj_token": baseToken, + "title": title, + }, + }, + }, + } +} + func TestBaseURLResolveRecordShareURL(t *testing.T) { t.Run("enriched", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 459dae04b8..d4a1417302 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -38,6 +38,7 @@ metadata: 进入任何需要目标 Base 的 shortcut 前,必须先拿到可用的 `base_token`,以及当前任务需要的 `table_id` / `view_id` / `record_id` / `form_id` / `dashboard_id` / `workflow_id` 等真实 ID;不要把完整 URL、wiki token、workspace token 或孤立 raw token 直接当作 `--base-token`。 - 用户输入 URL 或分享链接:先运行 `lark-cli base +url-resolve --url "" --as user`,用返回的 `base_token` 和相关 ID 继续后续命令。 +- Base/Wiki URL 的 `table=` query 参数实际表示当前选中的顶层 block,可能是数据表、仪表盘或 workflow;不要按参数名自行当成 `table_id`。以 `+url-resolve` 返回的 `block_type` 以及 `table_id` / `dashboard_id` / `workflow_id` 为准;`selection_source=url_query` 只说明 URL 当前选中了该 block,不代表它覆盖用户明确点名的目标。若用户点名的 dashboard 与 `block_name` 不一致,先用 `+dashboard-list` 按名称匹配;若只返回中性 `block_id`,按 hint 用 `+base-block-list` 确认类型。 - 用户输入 Base 标题、关键词或不确定名称:先运行 `lark-cli base +title-resolve --title "" --as user`;`--title` 传入标题中的短关键词,不超过 30 个字符;过长标题先取最有区分度的短关键词;多候选时先让用户消歧,不要猜。 - 文档嵌入 Base 标签:直接读取 `` / `` 的 `token` 作为 `--base-token`,`table-id` 作为 `--table-id`,`view-id` 作为 `--view-id`;孤立 raw token 不走 `+url-resolve`。 - 仍无法定位且用户不是要新建 Base 时,先反问用户要操作哪一个 Base;用户要新建时才用 `+base-create`。 diff --git a/skills/lark-base/references/lark-base-data-query.md b/skills/lark-base/references/lark-base-data-query.md index 8d9d701515..2fd3535684 100644 --- a/skills/lark-base/references/lark-base-data-query.md +++ b/skills/lark-base/references/lark-base-data-query.md @@ -79,16 +79,23 @@ lark-cli base +data-query \ | `--base-token ` | 是 | Base Token(base_token) | | `--dsl ` | 是 | LiteQuery Protocol JSON DSL 查询语句 | -## 如何从链接中提取参数 +## 如何从链接中解析参数 用户通常会提供如下 URL: +```text +https://example.feishu.cn/base/?table= ``` -https://example.feishu.cn/base/?table= + +不要直接把 URL 中的 `table=` 当成数据表 ID。它表示当前选中的 Base 顶层块,可能是数据表、仪表盘、工作流、文件夹或文档。先解析链接: + +```bash +lark-cli base +url-resolve --url "" --as user ``` -- `--base-token`:取 `/base/` 后面的字符串 -- DSL 中的 `tableId`:取 `table=` 后面的值 +- `--base-token`:使用返回的 `base_token` +- 仅当返回的 `block_type` 为 `table` 时,DSL 中的 `tableId` 才使用返回的 `table_id` +- 如果返回的是其他块类型,按 `hint.next_step` 继续处理;如果只返回中性的 `block_id`,先用 `+base-block-list` 确认块类型,再选择实际要查询的数据表 ## API 入参详情 diff --git a/tests/cli_e2e/base/base_basic_workflow_test.go b/tests/cli_e2e/base/base_basic_workflow_test.go index 114f19e96c..50c33097f7 100644 --- a/tests/cli_e2e/base/base_basic_workflow_test.go +++ b/tests/cli_e2e/base/base_basic_workflow_test.go @@ -5,6 +5,7 @@ package base import ( "context" + "fmt" "testing" "time" @@ -40,7 +41,7 @@ func TestBase_BasicWorkflow(t *testing.T) { }) tableName := "lark-cli-e2e-table-basic-" + clie2e.GenerateSuffix() - tableID, _, _ := createTableWithRetry( + tableID, _, primaryViewID := createTableWithRetry( t, parentT, ctx, @@ -50,6 +51,24 @@ func TestBase_BasicWorkflow(t *testing.T) { `{"name":"Main","type":"grid"}`, ) + t.Run("resolve table URL as bot", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+url-resolve", + "--url", fmt.Sprintf("https://example.larkoffice.com/base/%s?table=%s&view=%s", baseToken, tableID, primaryViewID), + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, baseToken, gjson.Get(result.Stdout, "data.base_token").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.block_id").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "table", gjson.Get(result.Stdout, "data.block_type").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table_id").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, primaryViewID, gjson.Get(result.Stdout, "data.view_id").String(), "stdout:\n%s", result.Stdout) + }) + t.Run("get table as bot", func(t *testing.T) { result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID}, diff --git a/tests/cli_e2e/base/base_url_resolve_dryrun_test.go b/tests/cli_e2e/base/base_url_resolve_dryrun_test.go new file mode 100644 index 0000000000..51a7c4420b --- /dev/null +++ b/tests/cli_e2e/base/base_url_resolve_dryrun_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +func TestBaseURLResolveSelectedBlockDryRun(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+url-resolve", + "--url", "https://example.larkoffice.com/base/app_x?table=blk_selected", + "--dry-run", + }, + BinaryPath: "../../../lark-cli", + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + require.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String(), result.Stdout) + require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", clie2e.DryRunGet(result.Stdout, "api.0.url").String(), result.Stdout) + require.Equal(t, "blk_selected", clie2e.DryRunGet(result.Stdout, "selected_block_id").String(), result.Stdout) +} + +func TestBaseURLResolveWikiSelectedBlockDryRun(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+url-resolve", + "--url", "https://example.larkoffice.com/wiki/wik_x?table=wkf_selected", + "--dry-run", + }, + BinaryPath: "../../../lark-cli", + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + require.Equal(t, "GET", clie2e.DryRunGet(result.Stdout, "api.0.method").String(), result.Stdout) + require.Equal(t, "/open-apis/wiki/v2/spaces/get_node", clie2e.DryRunGet(result.Stdout, "api.0.url").String(), result.Stdout) + require.Equal(t, "wik_x", clie2e.DryRunGet(result.Stdout, "api.0.params.token").String(), result.Stdout) + require.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.1.method").String(), result.Stdout) + require.Equal(t, "/open-apis/base/v3/bases/%3Cobj_token%20from%20step%201%3E/blocks/list", clie2e.DryRunGet(result.Stdout, "api.1.url").String(), result.Stdout) + require.Equal(t, "wkf_selected", clie2e.DryRunGet(result.Stdout, "selected_block_id").String(), result.Stdout) +}