Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/httpmock/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
166 changes: 151 additions & 15 deletions shortcuts/base/base_resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/<base_token>?table=<table_id>&view=<view_id>"`,
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<block_id>&view=<view_id>"`,
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
Expand All @@ -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 != "" {
Comment thread
zhouyue-bytedance marked this conversation as resolved.
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", "<obj_token from step 1>").
Set("selected_block_id", selectedBlockID)
case "record_share_url":
return common.NewDryRunAPI().
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
Expand Down Expand Up @@ -170,14 +195,17 @@ 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":
out, err := resolveWikiBaseURL(runtime, parsed)
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":
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
if err != nil {
out["hint"] = resolveHint(tableID, nil)
Expand All @@ -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"))
Expand Down
Loading
Loading