From 705c5cd7a8e03b2657ddcecffba97d261e22b229 Mon Sep 17 00:00:00 2001 From: shanglei Date: Tue, 28 Jul 2026 19:16:16 +0800 Subject: [PATCH 01/27] feat(contact): add bot search shortcut --- shortcuts/contact/contact_search_bot.go | 330 ++++++++++++ shortcuts/contact/contact_search_bot_test.go | 488 ++++++++++++++++++ shortcuts/contact/shortcuts.go | 1 + skills/lark-contact/SKILL.md | 29 +- .../contact_search_bot_workflow_test.go | 35 ++ .../dryrun/contact_search_bot_dryrun_test.go | 50 ++ 6 files changed, 931 insertions(+), 2 deletions(-) create mode 100644 shortcuts/contact/contact_search_bot.go create mode 100644 shortcuts/contact/contact_search_bot_test.go create mode 100644 tests/cli_e2e/contact/contact_search_bot_workflow_test.go create mode 100644 tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go new file mode 100644 index 0000000000..2e329c2015 --- /dev/null +++ b/shortcuts/contact/contact_search_bot.go @@ -0,0 +1,330 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +const botSearchURL = "/open-apis/bot/v4/bot/search" + +const ( + maxBotSearchQueryChars = 50 + maxBotSearchChatIDs = 100 +) + +var botDisplayInfoHighlightRE = regexp.MustCompile(`(.*?)`) + +type botSearchAPIRequest struct { + Query string `json:"query,omitempty"` + Filter *botSearchAPIFilter `json:"filter,omitempty"` +} + +// HasChatter uses omitempty: validation rejects =false, so a set field is always +// true and an unset field stays out of the request entirely. +type botSearchAPIFilter struct { + ChatIDs []string `json:"chat_ids,omitempty"` + HasChatter bool `json:"has_chatter,omitempty"` +} + +type botSearchAPIData struct { + Items []botSearchAPIItem `json:"items"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + Notice string `json:"notice"` +} + +type botSearchAPIItem struct { + ID string `json:"id"` + DisplayInfo string `json:"display_info"` + MetaData botSearchAPIMeta `json:"meta_data"` +} + +type botSearchAPIMeta struct { + TenantID string `json:"tenant_id"` + EnableJoinGroup bool `json:"enable_join_group"` + ChatID string `json:"chat_id"` + IsAgent bool `json:"is_agent"` +} + +type searchBot struct { + OpenID string `json:"open_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + P2PChatID string `json:"p2p_chat_id,omitempty"` + HasChatted bool `json:"has_chatted"` + EnableJoinGroup bool `json:"enable_join_group"` + IsAgent bool `json:"is_agent"` + TenantID string `json:"tenant_id,omitempty"` + MatchSegments []string `json:"match_segments"` +} + +type searchBotResponse struct { + Bots []searchBot `json:"bots"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token,omitempty"` + Notice string `json:"notice,omitempty"` +} + +var ContactSearchBot = common.Shortcut{ + Service: "contact", + Command: "+search-bot", + Description: "Search bots (apps) visible to the calling user by keyword, chat, or chat history (requires --as user)", + Risk: "read", + Scopes: []string{"search:bot"}, + AuthTypes: []string{"user"}, + Flags: []common.Flag{ + {Name: "query", Desc: "search keyword, required (≤ 50 characters)"}, + {Name: "chat-ids", Desc: "narrow --query to bots in these chats (CSV of chat_id; ≤ 100)"}, + {Name: "has-chatted", Type: "bool", Desc: "narrow --query to bots you've chatted with (omit to disable; =false rejected)"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request"}, + {Name: "page-token", Desc: "pagination token from a previous response"}, + }, + Tips: []string{ + "Keyword search: lark-cli contact +search-bot --query '会议助手' --as user", + "Narrow to bots in a chat: lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user", + "Narrow to bots you've chatted with: lark-cli contact +search-bot --query '助手' --has-chatted --as user", + "--query is required; --chat-ids and --has-chatted only narrow it. on has_more=true use --format json to read page_token, then pass --page-token to continue — there is no auto-pagination.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBotSearch(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, err := buildBotSearchBody(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + params := map[string]interface{}{"page_size": runtime.Int("page-size")} + if pageToken := runtime.Str("page-token"); pageToken != "" { + params["page_token"] = pageToken + } + return common.NewDryRunAPI().POST(botSearchURL).Params(params).Body(body) + }, + Execute: executeBotSearch, +} + +func botSearchQueryRequiredError() error { + return common.ValidationErrorf("--query is required: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API returns an empty list for filter-only requests)"). + WithParam("--query") +} + +func validateBotSearch(runtime *common.RuntimeContext) error { + query := strings.TrimSpace(runtime.Str("query")) + if query == "" { + return botSearchQueryRequiredError() + } + if utf8.RuneCountInString(query) > maxBotSearchQueryChars { + return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars). + WithParam("--query") + } + + if runtime.Cmd.Flags().Changed("chat-ids") { + raw := strings.TrimSpace(runtime.Str("chat-ids")) + chatIDs := common.SplitCSV(raw) + if len(chatIDs) == 0 { + return common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw). + WithParam("--chat-ids") + } + if len(chatIDs) > maxBotSearchChatIDs { + return common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs). + WithParam("--chat-ids") + } + } + + // Agents passing =false almost always mean "do not filter", but the API + // reads it as "must NOT match". A hard error prevents silent wrong results. + if runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted") { + return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)"). + WithParam("--has-chatted") + } + + if runtime.Int("page-size") < 1 { + return common.ValidationErrorf("--page-size: must be at least 1").WithParam("--page-size") + } + return nil +} + +func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) { + req := &botSearchAPIRequest{Query: strings.TrimSpace(runtime.Str("query"))} + filter := &botSearchAPIFilter{} + hasFilter := false + + if runtime.Cmd.Flags().Changed("chat-ids") { + raw := strings.TrimSpace(runtime.Str("chat-ids")) + chatIDs := common.SplitCSV(raw) + if len(chatIDs) == 0 { + return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw). + WithParam("--chat-ids") + } + filter.ChatIDs = chatIDs + hasFilter = true + } + if runtime.Cmd.Flags().Changed("has-chatted") && runtime.Bool("has-chatted") { + filter.HasChatter = true + hasFilter = true + } + + if hasFilter { + req.Filter = filter + } + return req, nil +} + +func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error { + body, err := buildBotSearchBody(runtime) + if err != nil { + return err + } + + queryParams := larkcore.QueryParams{ + "page_size": []string{strconv.Itoa(runtime.Int("page-size"))}, + } + if pageToken := runtime.Str("page-token"); pageToken != "" { + queryParams["page_token"] = []string{pageToken} + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: botSearchURL, + Body: body, + QueryParams: queryParams, + }) + if err != nil { + return err + } + + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return err + } + respData, err := decodeBotSearchAPIData(data) + if err != nil { + return err + } + + bots := projectBots(respData) + out := searchBotResponse{ + Bots: bots, + HasMore: respData.HasMore, + PageToken: respData.PageToken, + Notice: respData.Notice, + } + requestedFormat := runtime.Format + if requestedFormat == "table" { + // The framework's generic table formatter would flatten searchBotResponse + // and bypass the command's frozen six-column renderer. + runtime.Format = "pretty" + } + runtime.OutFormat(out, &output.Meta{Count: len(bots)}, func(w io.Writer) { + if len(bots) == 0 { + fmt.Fprintln(w, "No bots found.") + return + } + output.PrintTable(w, prettyBotRows(bots)) + }) + runtime.Format = requestedFormat + if respData.HasMore && isHumanReadableFormat(requestedFormat) { + fmt.Fprintln(runtime.IO().ErrOut, + "\nhint: more matches exist; use --format json to read page_token, then pass --page-token to continue") + } + return nil +} + +func decodeBotSearchAPIData(data map[string]interface{}) (*botSearchAPIData, error) { + raw, err := json.Marshal(data) + if err != nil { + return nil, contactInvalidResponseError("marshal bot search response data failed").WithCause(err) + } + var out botSearchAPIData + if err := json.Unmarshal(raw, &out); err != nil { + return nil, contactInvalidResponseError("decode bot search response data failed").WithCause(err) + } + return &out, nil +} + +func projectBots(data *botSearchAPIData) []searchBot { + if data == nil { + return []searchBot{} + } + bots := make([]searchBot, 0, len(data.Items)) + for i := range data.Items { + item := &data.Items[i] + name, description, segments := parseBotDisplayInfo(item.DisplayInfo, item.ID) + // Despite the API documentation, meta_data.chat_id is the caller's p2p + // chat with the bot, not a group that contains the bot. + p2pChatID := item.MetaData.ChatID + bots = append(bots, searchBot{ + OpenID: item.ID, + Name: name, + Description: description, + P2PChatID: p2pChatID, + HasChatted: p2pChatID != "", + EnableJoinGroup: item.MetaData.EnableJoinGroup, + IsAgent: item.MetaData.IsAgent, + TenantID: item.MetaData.TenantID, + MatchSegments: segments, + }) + } + return bots +} + +func parseBotDisplayInfo(raw, openID string) (name, description string, matchSegments []string) { + matchSegments = make([]string, 0) + for _, match := range botDisplayInfoHighlightRE.FindAllStringSubmatch(raw, -1) { + matchSegments = append(matchSegments, match[1]) + } + + lines := strings.Split(raw, "\n") + stripTags := func(value string) string { + value = strings.ReplaceAll(value, "", "") + value = strings.ReplaceAll(value, "", "") + return strings.TrimSpace(value) + } + if len(lines) > 0 { + name = stripTags(lines[0]) + } + if name == "" { + for _, line := range lines { + if candidate := stripTags(line); candidate != "" { + name = candidate + break + } + } + } + if name == "" { + name = openID + } + if len(lines) > 1 { + description = stripTags(lines[1]) + } + return name, description, matchSegments +} + +// map[] shape is required by output.PrintTable. +func prettyBotRows(bots []searchBot) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(bots)) + for _, bot := range bots { + rows = append(rows, map[string]interface{}{ + "name": bot.Name, + "description": common.TruncateStr(bot.Description, 50), + "has_chatted": bot.HasChatted, + "is_agent": bot.IsAgent, + "enable_join_group": bot.EnableJoinGroup, + "open_id": bot.OpenID, + }) + } + return rows +} diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go new file mode 100644 index 0000000000..dcc12c77d6 --- /dev/null +++ b/shortcuts/contact/contact_search_bot_test.go @@ -0,0 +1,488 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func newBotSearchTestCommand() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("query", "", "") + cmd.Flags().String("chat-ids", "", "") + cmd.Flags().Bool("has-chatted", false, "") + cmd.Flags().Int("page-size", 20, "") + cmd.Flags().String("page-token", "", "") + return cmd +} + +func botSearchDefaultConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "test", AppSecret: "test", Brand: core.BrandFeishu, + UserOpenId: "ou_self", + } +} + +func setBotSearchFlag(t *testing.T, cmd *cobra.Command, name, value string) { + t.Helper() + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s=%q: %v", name, value, err) + } +} + +func assertBotSearchValidationProblem(t *testing.T, err error, wantParam string) { + t.Helper() + if err == nil { + t.Fatal("expected validation error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem: got %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if validationErr.Param != wantParam { + t.Fatalf("param: got %q, want %q", validationErr.Param, wantParam) + } +} + +func TestValidateBotSearchErrors(t *testing.T) { + chatIDs := make([]string, 101) + for i := range chatIDs { + chatIDs[i] = fmt.Sprintf("chat_%03d", i) + } + + tests := []struct { + name string + flags map[string]string + wantParam string + wantMessage string + }{ + { + name: "query missing", + wantParam: "--query", + wantMessage: "--query is required: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API returns an empty list for filter-only requests)", + }, + { + name: "query over 50 characters", + flags: map[string]string{"query": strings.Repeat("中", 51)}, + wantParam: "--query", + wantMessage: "--query: length must be between 1 and 50 characters", + }, + { + name: "chat ids parse empty", + flags: map[string]string{"query": "x", "chat-ids": " , , "}, + wantParam: "--chat-ids", + wantMessage: "--chat-ids: no valid chat_id parsed from \", ,\" (separate entries with ',')", + }, + { + name: "over 100 chat ids", + flags: map[string]string{"query": "x", "chat-ids": strings.Join(chatIDs, ",")}, + wantParam: "--chat-ids", + wantMessage: "--chat-ids: must be at most 100 entries", + }, + { + name: "has chatted false", + flags: map[string]string{"query": "x", "has-chatted": "false"}, + wantParam: "--has-chatted", + wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)", + }, + { + name: "page size below one", + flags: map[string]string{"query": "x", "page-size": "0"}, + wantParam: "--page-size", + wantMessage: "--page-size: must be at least 1", + }, + { + name: "chat ids without query", + flags: map[string]string{"chat-ids": "oc_a"}, + wantParam: "--query", + wantMessage: "--query is required: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API returns an empty list for filter-only requests)", + }, + { + name: "has chatted without query", + flags: map[string]string{"has-chatted": "true"}, + wantParam: "--query", + wantMessage: "--query is required: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API returns an empty list for filter-only requests)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newBotSearchTestCommand() + for name, value := range tt.flags { + setBotSearchFlag(t, cmd, name, value) + } + runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig()) + err := validateBotSearch(runtime) + assertBotSearchValidationProblem(t, err, tt.wantParam) + if err.Error() != tt.wantMessage { + t.Fatalf("message: got %q, want %q", err.Error(), tt.wantMessage) + } + }) + } +} + +func TestValidateBotSearchPassingCases(t *testing.T) { + tests := []struct { + name string + flags map[string]string + }{ + {name: "query only", flags: map[string]string{"query": "x"}}, + {name: "query and chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}}, + {name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}}, + {name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newBotSearchTestCommand() + for name, value := range tt.flags { + setBotSearchFlag(t, cmd, name, value) + } + runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig()) + if err := validateBotSearch(runtime); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateBotSearchQueryRuneBoundary(t *testing.T) { + for _, tt := range []struct { + name string + query string + wantError bool + }{ + {name: "50 CJK characters", query: strings.Repeat("中", 50)}, + {name: "51 CJK characters", query: strings.Repeat("中", 51), wantError: true}, + } { + t.Run(tt.name, func(t *testing.T) { + cmd := newBotSearchTestCommand() + setBotSearchFlag(t, cmd, "query", tt.query) + runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig()) + err := validateBotSearch(runtime) + if tt.wantError { + assertBotSearchValidationProblem(t, err, "--query") + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestBuildBotSearchBody(t *testing.T) { + tests := []struct { + name string + flags map[string]string + wantJSON string + }{ + {name: "query only", flags: map[string]string{"query": "x"}, wantJSON: `{"query":"x"}`}, + {name: "chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`}, + {name: "has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"has_chatter":true}}`}, + {name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newBotSearchTestCommand() + for name, value := range tt.flags { + setBotSearchFlag(t, cmd, name, value) + } + runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig()) + body, err := buildBotSearchBody(runtime) + if err != nil { + t.Fatalf("build body: %v", err) + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + if string(raw) != tt.wantJSON { + t.Fatalf("body: got %s, want %s", raw, tt.wantJSON) + } + }) + } +} + +func TestParseBotDisplayInfo(t *testing.T) { + tests := []struct { + name string + raw string + openID string + wantName string + wantDescription string + wantSegments []string + }{ + {name: "single live match", raw: "会议助手\n推送未接会议消息提醒", openID: "ou_a", wantName: "会议助手", wantDescription: "推送未接会议消息提醒", wantSegments: []string{"会议助手"}}, + {name: "multiple live matches", raw: "会议助手\n你的专属会议室小管家", openID: "ou_b", wantName: "会议室助手", wantDescription: "你的专属会议室小管家", wantSegments: []string{"会议", "助手"}}, + {name: "empty live description", raw: "尚磊的智能助手\n", openID: "ou_c", wantName: "尚磊的智能助手", wantSegments: []string{"助手"}}, + {name: "mid-name live match", raw: "红黑榜小手\n每天定时发送阻塞红黑榜Bug看板", openID: "ou_d", wantName: "红黑榜小助手", wantDescription: "每天定时发送阻塞红黑榜Bug看板", wantSegments: []string{"助"}}, + {name: "no newline", raw: "会议助手", openID: "ou_e", wantName: "会议助手", wantSegments: []string{}}, + {name: "empty", raw: "", openID: "ou_f", wantName: "ou_f", wantSegments: []string{}}, + {name: "fallback line", raw: "\n\n真名", openID: "ou_g", wantName: "真名", wantSegments: []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, description, segments := parseBotDisplayInfo(tt.raw, tt.openID) + if name != tt.wantName || description != tt.wantDescription { + t.Fatalf("name/description: got %q/%q, want %q/%q", name, description, tt.wantName, tt.wantDescription) + } + if segments == nil { + t.Fatal("match segments must be an empty slice, not nil") + } + if fmt.Sprint(segments) != fmt.Sprint(tt.wantSegments) { + t.Fatalf("match segments: got %v, want %v", segments, tt.wantSegments) + } + }) + } +} + +func TestProjectBotsMapsEveryField(t *testing.T) { + data := &botSearchAPIData{Items: []botSearchAPIItem{ + { + ID: "ou_with_chat", + DisplayInfo: "会议助手\n提醒助手", + MetaData: botSearchAPIMeta{ + TenantID: "1", EnableJoinGroup: true, ChatID: "oc_p2p", IsAgent: true, + }, + }, + { + ID: "ou_without_chat", + DisplayInfo: "无会话机器人", + MetaData: botSearchAPIMeta{TenantID: "1"}, + }, + }} + + bots := projectBots(data) + if len(bots) != 2 { + t.Fatalf("bots: got %d, want 2", len(bots)) + } + first := bots[0] + if first.OpenID != "ou_with_chat" || first.Name != "会议助手" || first.Description != "提醒助手" || + first.P2PChatID != "oc_p2p" || !first.HasChatted || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" || + fmt.Sprint(first.MatchSegments) != "[会议助手]" { + t.Fatalf("first bot mapping: %+v", first) + } + second := bots[1] + if second.P2PChatID != "" || second.HasChatted { + t.Fatalf("second bot chat fields: %+v", second) + } + raw, err := json.Marshal(searchBotResponse{Bots: bots}) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + if strings.Contains(string(raw), `"p2p_chat_id":""`) { + t.Fatalf("empty p2p_chat_id must be omitted: %s", raw) + } +} + +func TestProjectBotsEmptySerializesAsArray(t *testing.T) { + bots := projectBots(&botSearchAPIData{Items: []botSearchAPIItem{}}) + if bots == nil { + t.Fatal("bots must be an empty slice, not nil") + } + raw, err := json.Marshal(searchBotResponse{Bots: bots}) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + if string(raw) != `{"bots":[],"has_more":false}` { + t.Fatalf("response: got %s", raw) + } +} + +func botSearchStub(url string, pageToken string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "POST", + URL: url, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "notice": "The query is too long and has been truncated to the first 50 characters for search.", + "has_more": true, + "page_token": pageToken, + "items": []interface{}{ + map[string]interface{}{ + "id": "ou_bot", + "display_info": "会议助手\n推送未接会议消息提醒", + "meta_data": map[string]interface{}{ + "tenant_id": "1", "enable_join_group": true, "chat_id": "oc_p2p", "is_agent": false, + }, + }, + }, + }, + }, + } +} + +func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) { + factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + stub := botSearchStub(botSearchURL+"?page_size=25&page_token=cursor_in", "cursor_out") + registry.Register(stub) + + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--query", "助手", "--chat-ids", "oc_a,oc_b", "--has-chatted", + "--page-size", "25", "--page-token", "cursor_in", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + + var requestBody map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil { + t.Fatalf("request body: %v", err) + } + if requestBody["query"] != "助手" { + t.Fatalf("request query: got %v", requestBody["query"]) + } + filter, ok := requestBody["filter"].(map[string]interface{}) + if !ok || filter["has_chatter"] != true || fmt.Sprint(filter["chat_ids"]) != "[oc_a oc_b]" { + t.Fatalf("request filter: %#v", requestBody["filter"]) + } + + var envelope struct { + Data searchBotResponse `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("response JSON: %v\n%s", err, stdout.String()) + } + if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || envelope.Data.PageToken != "cursor_out" || !envelope.Data.HasMore { + t.Fatalf("response pass-through: %+v", envelope.Data) + } + if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].P2PChatID != "oc_p2p" { + t.Fatalf("bots: %+v", envelope.Data.Bots) + } + registry.Verify(t) +} + +func TestBotSearchIntegrationEmptyPageTokenOmitted(t *testing.T) { + factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + registry.Register(botSearchStub(botSearchURL+"?page_size=20", "")) + + err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "助手", "--format", "json", "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("response JSON: %v", err) + } + data := envelope["data"].(map[string]interface{}) + if _, ok := data["page_token"]; ok { + t.Fatalf("empty page_token must be omitted: %v", data) + } +} + +func TestBotSearchHumanReadableOutputAndPaginationHint(t *testing.T) { + for _, format := range []string{"pretty", "table"} { + t.Run(format, func(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out")) + + err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "助手", "--format", format, "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, column := range []string{"name", "description", "has_chatted", "is_agent", "enable_join_group", "open_id"} { + if !strings.Contains(stdout.String(), column) { + t.Errorf("%s output missing %q: %s", format, column, stdout.String()) + } + } + for _, genericField := range []string{"bots", "has_more", "page_token", "notice", "tenant_id", "p2p_chat_id", "match_segments"} { + if strings.Contains(stdout.String(), genericField) { + t.Errorf("%s output used the generic formatter and exposed %q: %s", format, genericField, stdout.String()) + } + } + wantHint := "\nhint: more matches exist; use --format json to read page_token, then pass --page-token to continue\n" + if stderr.String() != wantHint { + t.Fatalf("%s stderr: got %q, want %q", format, stderr.String(), wantHint) + } + }) + } +} + +func TestBotSearchPrettyEmptyResult(t *testing.T) { + factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + registry.Register(&httpmock.Stub{ + Method: "POST", + URL: botSearchURL + "?page_size=20", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"items": []interface{}{}, "has_more": false}, + }, + }) + + err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "none", "--format", "pretty", "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(stdout.String(), "No bots found.") { + t.Fatalf("pretty output: %q", stdout.String()) + } +} + +func TestBotSearchDryRunMirrorsRequest(t *testing.T) { + factory, stdout, _, _ := cmdutil.TestFactory(t, botSearchDefaultConfig()) + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--query", "助手", "--chat-ids", "oc_a", "--has-chatted", + "--page-size", "25", "--page-token", " cursor ", "--dry-run", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + var envelope struct { + Data struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body botSearchAPIRequest `json:"body"` + } `json:"api"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("dry-run JSON: %v", err) + } + if len(envelope.Data.API) != 1 { + t.Fatalf("api calls: got %d, want 1", len(envelope.Data.API)) + } + call := envelope.Data.API[0] + if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) || call.Params["page_token"] != " cursor " { + t.Fatalf("dry-run call: %+v", call) + } + if call.Body.Query != "助手" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter { + t.Fatalf("dry-run body: %+v", call.Body) + } +} + +func TestDecodeBotSearchAPIDataMarshalFailureTyped(t *testing.T) { + _, err := decodeBotSearchAPIData(map[string]interface{}{"bad": func() {}}) + if err == nil { + t.Fatal("expected marshal failure") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("problem: %+v, ok=%v", problem, ok) + } +} diff --git a/shortcuts/contact/shortcuts.go b/shortcuts/contact/shortcuts.go index ace3b0fa55..f98570a411 100644 --- a/shortcuts/contact/shortcuts.go +++ b/shortcuts/contact/shortcuts.go @@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common" func Shortcuts() []common.Shortcut { return []common.Shortcut{ ContactSearchUser, + ContactSearchBot, ContactGetUser, } } diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index ea61830408..a58042c750 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-contact version: 1.0.0 -description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" +description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人。当用户提到某人姓名要下一步发消息 / 排日程,拿到 open_id 想查具体信息,或需要查找机器人 open_id 时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" metadata: requires: bins: ["lark-cli"] @@ -15,6 +15,7 @@ metadata: | 想做什么 | user 身份 | bot 身份 | |---|---|---| | 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 | +| 按名称搜索当前用户可见的机器人 | `+search-bot --query <关键词>` | 不支持 | | 已知 open_id 取他人资料 | `+search-user --user-ids ` | [`+get-user --user-id `](references/lark-contact-get-user.md) | | 查看自己 | `+get-user` 或 `+search-user --user-ids me` | 不支持 | | 查同事的个人状态 / 签名 | `user_profiles batch_query` | 不支持 | @@ -42,9 +43,33 @@ lark-cli contact user_profiles batch_query \ 搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。 +## 搜索机器人 + +`+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人。返回的 `open_id` 是 `ou_` 开头的机器人 open_id,可用于后续操作。`p2p_chat_id` 表示当前用户与机器人的单聊会话,`has_chatted` 表示是否存在该会话。 + +按关键词搜索: + +```bash +lark-cli contact +search-bot --query '会议助手' --as user +``` + +`--chat-ids` 和 `--has-chatted` 只能缩小关键词搜索范围,每次调用仍须传入 `--query`: + +```bash +lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user +lark-cli contact +search-bot --query '助手' --has-chatted --as user +``` + +返回 `has_more=true` 时,使用 JSON 格式读取 `page_token`,再传给下一次请求。该命令不会自动获取后续页面: + +```bash +lark-cli contact +search-bot --query '助手' --format json --as user +lark-cli contact +search-bot --query '助手' --page-token cursor_xxx --format json --as user +``` + ## 注意事项 -- **41050 / Permission denied** 受当前身份的可见范围限制(两条命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。 +- **41050 / Permission denied** 受当前身份的可见范围限制(这些命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。 - **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。 - **ID 类型**:默认 `open_id`。`+get-user` 可改 `--user-id-type union_id|user_id`;`+search-user` 只接受 `open_id`。 diff --git a/tests/cli_e2e/contact/contact_search_bot_workflow_test.go b/tests/cli_e2e/contact/contact_search_bot_workflow_test.go new file mode 100644 index 0000000000..436adb20e9 --- /dev/null +++ b/tests/cli_e2e/contact/contact_search_bot_workflow_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestContactSearchBotWorkflowAsUser(t *testing.T) { + clie2e.SkipWithoutUserToken(t) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"contact", "+search-bot", "--query", "助", "--format", "json"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + bots := gjson.Get(result.Stdout, "data.bots") + require.True(t, bots.IsArray(), "data.bots must be an array; stdout:\n%s", result.Stdout) + require.True(t, gjson.Get(result.Stdout, "data.has_more").Exists(), "data.has_more must be present; stdout:\n%s", result.Stdout) + for _, bot := range bots.Array() { + require.NotEmpty(t, bot.Get("open_id").String(), "every bot must carry open_id; stdout:\n%s", result.Stdout) + } +} diff --git a/tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go b/tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go new file mode 100644 index 0000000000..8cdb3a3642 --- /dev/null +++ b/tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package dryrun + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +func TestContactSearchBotDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "contact_search_bot_dryrun") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "contact_search_bot_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "contact", "+search-bot", + "--query", "助手", + "--chat-ids", "oc_a,oc_b", + "--has-chatted", + "--page-size", "25", + "--page-token", "cursor_in", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out) + require.Equal(t, "/open-apis/bot/v4/bot/search", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out) + require.Equal(t, int64(25), clie2e.DryRunGet(out, "api.0.params.page_size").Int(), "stdout:\n%s", out) + require.Equal(t, "cursor_in", clie2e.DryRunGet(out, "api.0.params.page_token").String(), "stdout:\n%s", out) + require.Equal(t, "助手", clie2e.DryRunGet(out, "api.0.body.query").String(), "stdout:\n%s", out) + require.Equal(t, []string{"oc_a", "oc_b"}, []string{ + clie2e.DryRunGet(out, "api.0.body.filter.chat_ids.0").String(), + clie2e.DryRunGet(out, "api.0.body.filter.chat_ids.1").String(), + }, "stdout:\n%s", out) + require.True(t, clie2e.DryRunGet(out, "api.0.body.filter.has_chatter").Bool(), "stdout:\n%s", out) +} From 2830d5e2660485ba1c7faad138eb695fa68bd93d Mon Sep 17 00:00:00 2001 From: shanglei Date: Tue, 28 Jul 2026 19:39:01 +0800 Subject: [PATCH 02/27] fix(contact): align bot search pagination and formats --- shortcuts/contact/contact_search_bot.go | 32 +++++---- shortcuts/contact/contact_search_bot_test.go | 72 ++++++++++++++++---- skills/lark-contact/SKILL.md | 14 ++-- 3 files changed, 79 insertions(+), 39 deletions(-) diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 2e329c2015..d999361421 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -24,6 +24,7 @@ const botSearchURL = "/open-apis/bot/v4/bot/search" const ( maxBotSearchQueryChars = 50 maxBotSearchChatIDs = 100 + maxBotSearchPageSize = 50 ) var botDisplayInfoHighlightRE = regexp.MustCompile(`(.*?)`) @@ -90,7 +91,7 @@ var ContactSearchBot = common.Shortcut{ {Name: "query", Desc: "search keyword, required (≤ 50 characters)"}, {Name: "chat-ids", Desc: "narrow --query to bots in these chats (CSV of chat_id; ≤ 100)"}, {Name: "has-chatted", Type: "bool", Desc: "narrow --query to bots you've chatted with (omit to disable; =false rejected)"}, - {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-50"}, {Name: "page-token", Desc: "pagination token from a previous response"}, }, Tips: []string{ @@ -107,8 +108,9 @@ var ContactSearchBot = common.Shortcut{ if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } - params := map[string]interface{}{"page_size": runtime.Int("page-size")} - if pageToken := runtime.Str("page-token"); pageToken != "" { + pageSize, pageToken := botSearchPagination(runtime) + params := map[string]interface{}{"page_size": pageSize} + if pageToken != "" { params["page_token"] = pageToken } return common.NewDryRunAPI().POST(botSearchURL).Params(params).Body(body) @@ -151,12 +153,18 @@ func validateBotSearch(runtime *common.RuntimeContext) error { WithParam("--has-chatted") } - if runtime.Int("page-size") < 1 { - return common.ValidationErrorf("--page-size: must be at least 1").WithParam("--page-size") + if n := runtime.Int("page-size"); n < 1 || n > maxBotSearchPageSize { + return common.ValidationErrorf("--page-size: must be between 1 and %d", maxBotSearchPageSize). + WithParam("--page-size") } return nil } +func botSearchPagination(runtime *common.RuntimeContext) (int, string) { + // Page tokens are opaque server values; preserve them verbatim. + return runtime.Int("page-size"), runtime.Str("page-token") +} + func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) { req := &botSearchAPIRequest{Query: strings.TrimSpace(runtime.Str("query"))} filter := &botSearchAPIFilter{} @@ -189,10 +197,11 @@ func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error return err } + pageSize, pageToken := botSearchPagination(runtime) queryParams := larkcore.QueryParams{ - "page_size": []string{strconv.Itoa(runtime.Int("page-size"))}, + "page_size": []string{strconv.Itoa(pageSize)}, } - if pageToken := runtime.Str("page-token"); pageToken != "" { + if pageToken != "" { queryParams["page_token"] = []string{pageToken} } @@ -222,12 +231,6 @@ func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error PageToken: respData.PageToken, Notice: respData.Notice, } - requestedFormat := runtime.Format - if requestedFormat == "table" { - // The framework's generic table formatter would flatten searchBotResponse - // and bypass the command's frozen six-column renderer. - runtime.Format = "pretty" - } runtime.OutFormat(out, &output.Meta{Count: len(bots)}, func(w io.Writer) { if len(bots) == 0 { fmt.Fprintln(w, "No bots found.") @@ -235,8 +238,7 @@ func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error } output.PrintTable(w, prettyBotRows(bots)) }) - runtime.Format = requestedFormat - if respData.HasMore && isHumanReadableFormat(requestedFormat) { + if respData.HasMore && isHumanReadableFormat(runtime.Format) { fmt.Fprintln(runtime.IO().ErrOut, "\nhint: more matches exist; use --format json to read page_token, then pass --page-token to continue") } diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index dcc12c77d6..2d07bdceb7 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -108,7 +108,13 @@ func TestValidateBotSearchErrors(t *testing.T) { name: "page size below one", flags: map[string]string{"query": "x", "page-size": "0"}, wantParam: "--page-size", - wantMessage: "--page-size: must be at least 1", + wantMessage: "--page-size: must be between 1 and 50", + }, + { + name: "page size over 50", + flags: map[string]string{"query": "x", "page-size": "51"}, + wantParam: "--page-size", + wantMessage: "--page-size: must be between 1 and 50", }, { name: "chat ids without query", @@ -149,6 +155,7 @@ func TestValidateBotSearchPassingCases(t *testing.T) { {name: "query and chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}}, {name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}}, {name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}}, + {name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "50"}}, } for _, tt := range tests { @@ -394,8 +401,51 @@ func TestBotSearchIntegrationEmptyPageTokenOmitted(t *testing.T) { } } -func TestBotSearchHumanReadableOutputAndPaginationHint(t *testing.T) { - for _, format := range []string{"pretty", "table"} { +func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out")) + + err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "助手", "--format", "pretty", "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, column := range []string{"name", "description", "has_chatted", "is_agent", "enable_join_group", "open_id"} { + if !strings.Contains(stdout.String(), column) { + t.Errorf("pretty output missing %q: %s", column, stdout.String()) + } + } + for _, genericField := range []string{"bots", "has_more", "page_token", "notice", "tenant_id", "p2p_chat_id", "match_segments"} { + if strings.Contains(stdout.String(), genericField) { + t.Errorf("pretty output exposed %q: %s", genericField, stdout.String()) + } + } + wantHint := "\nhint: more matches exist; use --format json to read page_token, then pass --page-token to continue\n" + if stderr.String() != wantHint { + t.Fatalf("pretty stderr: got %q, want %q", stderr.String(), wantHint) + } +} + +func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out")) + + err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "助手", "--format", "table", "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, field := range []string{"open_id", "tenant_id", "p2p_chat_id", "match_segments"} { + if !strings.Contains(stdout.String(), field) { + t.Errorf("table output missing %q: %s", field, stdout.String()) + } + } + wantHint := "\nhint: more matches exist; use --format json to read page_token, then pass --page-token to continue\n" + if stderr.String() != wantHint { + t.Fatalf("table stderr: got %q, want %q", stderr.String(), wantHint) + } +} + +func TestBotSearchCSVAndNDJSONExposeFullFieldsWithoutPaginationHint(t *testing.T) { + for _, format := range []string{"csv", "ndjson"} { t.Run(format, func(t *testing.T) { factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out")) @@ -404,19 +454,13 @@ func TestBotSearchHumanReadableOutputAndPaginationHint(t *testing.T) { if err != nil { t.Fatalf("execute: %v", err) } - for _, column := range []string{"name", "description", "has_chatted", "is_agent", "enable_join_group", "open_id"} { - if !strings.Contains(stdout.String(), column) { - t.Errorf("%s output missing %q: %s", format, column, stdout.String()) - } - } - for _, genericField := range []string{"bots", "has_more", "page_token", "notice", "tenant_id", "p2p_chat_id", "match_segments"} { - if strings.Contains(stdout.String(), genericField) { - t.Errorf("%s output used the generic formatter and exposed %q: %s", format, genericField, stdout.String()) + for _, field := range []string{"open_id", "tenant_id", "p2p_chat_id", "match_segments"} { + if !strings.Contains(stdout.String(), field) { + t.Errorf("%s output missing %q: %s", format, field, stdout.String()) } } - wantHint := "\nhint: more matches exist; use --format json to read page_token, then pass --page-token to continue\n" - if stderr.String() != wantHint { - t.Fatalf("%s stderr: got %q, want %q", format, stderr.String(), wantHint) + if stderr.Len() != 0 { + t.Fatalf("%s stderr: got %q, want empty", format, stderr.String()) } }) } diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index a58042c750..fc77f75c9d 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -18,7 +18,7 @@ metadata: | 按名称搜索当前用户可见的机器人 | `+search-bot --query <关键词>` | 不支持 | | 已知 open_id 取他人资料 | `+search-user --user-ids ` | [`+get-user --user-id `](references/lark-contact-get-user.md) | | 查看自己 | `+get-user` 或 `+search-user --user-ids me` | 不支持 | -| 查同事的个人状态 / 签名 | `user_profiles batch_query` | 不支持 | +| 查同事的个人状态 / 签名 | [`lark-openapi-explorer`](../lark-openapi-explorer/SKILL.md) | 不支持 | 已知 open_id 只是想发消息 / 排日程,不必经过 contact —— 直接 [`lark-im`](../lark-im/SKILL.md) / [`lark-calendar`](../lark-calendar/SKILL.md)。 @@ -31,15 +31,7 @@ lark-cli contact +search-user --query "张三" --has-chatted --as user lark-cli im +messages-send --user-id ou_xxx --text "Hi!" ``` -批量查同事的个人状态 / 个性签名(先用 schema 看参数)。 - -```bash -lark-cli schema contact.user_profiles.batch_query -lark-cli contact user_profiles batch_query \ - --params '{"user_id_type":"open_id"}' \ - --data '{"user_ids":["ou_xxx","ou_yyy"],"query_option":{"include_personal_status":true,"include_description":true}}' \ - --as user -``` +批量查同事的个人状态 / 个性签名时,当前命令清单没有对应的内置 contact 命令,交给 [`lark-openapi-explorer`](../lark-openapi-explorer/SKILL.md) 查找原生 OpenAPI。 搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。 @@ -67,6 +59,8 @@ lark-cli contact +search-bot --query '助手' --format json --as user lark-cli contact +search-bot --query '助手' --page-token cursor_xxx --format json --as user ``` +`--format pretty` 使用六列摘要;`table`、`csv` 和 `ndjson` 与 `+search-user` 一样使用完整结果字段。 + ## 注意事项 - **41050 / Permission denied** 受当前身份的可见范围限制(这些命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。 From 86ec6888110696fb2b3924ef88fd759e51f18cac Mon Sep 17 00:00:00 2001 From: shanglei Date: Tue, 28 Jul 2026 20:05:28 +0800 Subject: [PATCH 03/27] fix(contact): validate bot search chat IDs --- shortcuts/contact/contact_search_bot.go | 49 +++++++++++++------- shortcuts/contact/contact_search_bot_test.go | 7 +++ skills/lark-contact/SKILL.md | 2 +- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index d999361421..833b3da697 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -133,17 +133,8 @@ func validateBotSearch(runtime *common.RuntimeContext) error { WithParam("--query") } - if runtime.Cmd.Flags().Changed("chat-ids") { - raw := strings.TrimSpace(runtime.Str("chat-ids")) - chatIDs := common.SplitCSV(raw) - if len(chatIDs) == 0 { - return common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw). - WithParam("--chat-ids") - } - if len(chatIDs) > maxBotSearchChatIDs { - return common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs). - WithParam("--chat-ids") - } + if _, err := parseBotSearchChatIDs(runtime); err != nil { + return err } // Agents passing =false almost always mean "do not filter", but the API @@ -165,18 +156,40 @@ func botSearchPagination(runtime *common.RuntimeContext) (int, string) { return runtime.Int("page-size"), runtime.Str("page-token") } +func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) { + if !runtime.Cmd.Flags().Changed("chat-ids") { + return nil, nil + } + raw := strings.TrimSpace(runtime.Str("chat-ids")) + chatIDs := common.SplitCSV(raw) + if len(chatIDs) == 0 { + return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw). + WithParam("--chat-ids") + } + if len(chatIDs) > maxBotSearchChatIDs { + return nil, common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs). + WithParam("--chat-ids") + } + for i, chatID := range chatIDs { + normalized, err := common.ValidateChatIDTyped("--chat-ids", chatID) + if err != nil { + return nil, err + } + chatIDs[i] = normalized + } + return chatIDs, nil +} + func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) { req := &botSearchAPIRequest{Query: strings.TrimSpace(runtime.Str("query"))} filter := &botSearchAPIFilter{} hasFilter := false - if runtime.Cmd.Flags().Changed("chat-ids") { - raw := strings.TrimSpace(runtime.Str("chat-ids")) - chatIDs := common.SplitCSV(raw) - if len(chatIDs) == 0 { - return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw). - WithParam("--chat-ids") - } + chatIDs, err := parseBotSearchChatIDs(runtime) + if err != nil { + return nil, err + } + if len(chatIDs) > 0 { filter.ChatIDs = chatIDs hasFilter = true } diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 2d07bdceb7..139b2b4cad 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -98,6 +98,12 @@ func TestValidateBotSearchErrors(t *testing.T) { wantParam: "--chat-ids", wantMessage: "--chat-ids: must be at most 100 entries", }, + { + name: "invalid chat id", + flags: map[string]string{"query": "x", "chat-ids": "bad"}, + wantParam: "--chat-ids", + wantMessage: "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)", + }, { name: "has chatted false", flags: map[string]string{"query": "x", "has-chatted": "false"}, @@ -205,6 +211,7 @@ func TestBuildBotSearchBody(t *testing.T) { }{ {name: "query only", flags: map[string]string{"query": "x"}, wantJSON: `{"query":"x"}`}, {name: "chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`}, + {name: "chat id URL normalized", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`}, {name: "has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"has_chatter":true}}`}, {name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`}, } diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index fc77f75c9d..16ee0ba4c3 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -63,7 +63,7 @@ lark-cli contact +search-bot --query '助手' --page-token cursor_xxx --format j ## 注意事项 -- **41050 / Permission denied** 受当前身份的可见范围限制(这些命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。 +- **41050 / Permission denied** 按命令处理:`+search-user` 只支持 user 身份,重新授权 `contact:user:search`;`+search-bot` 只支持 user 身份,重新授权 `search:bot`;`+get-user` 同时支持 user 和 bot,可改用具备对应通讯录权限的身份。身份与授权细节见 [`lark-shared`](../lark-shared/SKILL.md)。 - **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。 - **ID 类型**:默认 `open_id`。`+get-user` 可改 `--user-id-type union_id|user_id`;`+search-user` 只接受 `open_id`。 From 48a4ab63fcd440244d3a28ed6ae87026568a2214 Mon Sep 17 00:00:00 2001 From: shanglei Date: Tue, 28 Jul 2026 20:22:46 +0800 Subject: [PATCH 04/27] fix(contact): enforce bot search page limit --- shortcuts/contact/contact_search_bot.go | 4 ++-- shortcuts/contact/contact_search_bot_test.go | 10 +++++----- tests/cli_e2e/contact/coverage.md | 8 +++++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 833b3da697..55245e597e 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -24,7 +24,7 @@ const botSearchURL = "/open-apis/bot/v4/bot/search" const ( maxBotSearchQueryChars = 50 maxBotSearchChatIDs = 100 - maxBotSearchPageSize = 50 + maxBotSearchPageSize = 30 ) var botDisplayInfoHighlightRE = regexp.MustCompile(`(.*?)`) @@ -91,7 +91,7 @@ var ContactSearchBot = common.Shortcut{ {Name: "query", Desc: "search keyword, required (≤ 50 characters)"}, {Name: "chat-ids", Desc: "narrow --query to bots in these chats (CSV of chat_id; ≤ 100)"}, {Name: "has-chatted", Type: "bool", Desc: "narrow --query to bots you've chatted with (omit to disable; =false rejected)"}, - {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-50"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"}, {Name: "page-token", Desc: "pagination token from a previous response"}, }, Tips: []string{ diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 139b2b4cad..62475c690c 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -114,13 +114,13 @@ func TestValidateBotSearchErrors(t *testing.T) { name: "page size below one", flags: map[string]string{"query": "x", "page-size": "0"}, wantParam: "--page-size", - wantMessage: "--page-size: must be between 1 and 50", + wantMessage: "--page-size: must be between 1 and 30", }, { - name: "page size over 50", - flags: map[string]string{"query": "x", "page-size": "51"}, + name: "page size over 30", + flags: map[string]string{"query": "x", "page-size": "31"}, wantParam: "--page-size", - wantMessage: "--page-size: must be between 1 and 50", + wantMessage: "--page-size: must be between 1 and 30", }, { name: "chat ids without query", @@ -161,7 +161,7 @@ func TestValidateBotSearchPassingCases(t *testing.T) { {name: "query and chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}}, {name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}}, {name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}}, - {name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "50"}}, + {name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "30"}}, } for _, tt := range tests { diff --git a/tests/cli_e2e/contact/coverage.md b/tests/cli_e2e/contact/coverage.md index e4d852f740..2e1ee61298 100644 --- a/tests/cli_e2e/contact/coverage.md +++ b/tests/cli_e2e/contact/coverage.md @@ -1,13 +1,14 @@ # Contact CLI E2E Coverage ## Metrics -- Denominator: 2 leaf commands -- Covered: 1 -- Coverage: 50.0% +- Denominator: 3 leaf commands +- Covered: 2 +- Coverage: 66.7% ## Summary - TestContact_LookupWorkflowAsUser: proves the user lookup workflow through `get self as user` and `get self by open id as user`; reads the current user first and round-trips the returned `open_id` back into `+get-user`. - TestContact_LookupWorkflowAsBot: proves bot lookup through `discover user via api as bot` and `get user by open id as bot`; the raw API discovery step is fixture setup only and does not affect the domain denominator. +- TestContactSearchBotWorkflowAsUser: proves live bot search as user; validates `bots[]`, `has_more`, and a non-empty bot `open_id`. - Blocked area: `contact +search-user` did not reliably return the current user in UAT even when queried with self-derived identifiers, so it remains uncovered rather than being counted from a flaky tenant-dependent assertion. ## Command Table @@ -15,4 +16,5 @@ | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | | ✓ | contact +get-user | shortcut | contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self by open id as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsBot/get user by open id as bot | self lookup; `--user-id ` | | +| ✓ | contact +search-bot | shortcut | contact_search_bot_workflow_test.go::TestContactSearchBotWorkflowAsUser | `--query `; `--format json`; user identity | | | ✕ | contact +search-user | shortcut | | none | UAT did not reliably return the current user for self-derived queries, so stable write-after-read style proof is not available | From 50ee9971ecca29a33590af728b854789c7d5b55d Mon Sep 17 00:00:00 2001 From: shanglei Date: Tue, 28 Jul 2026 20:42:04 +0800 Subject: [PATCH 05/27] test(contact): require bot search result --- tests/cli_e2e/contact/contact_search_bot_workflow_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/cli_e2e/contact/contact_search_bot_workflow_test.go b/tests/cli_e2e/contact/contact_search_bot_workflow_test.go index 436adb20e9..3006148823 100644 --- a/tests/cli_e2e/contact/contact_search_bot_workflow_test.go +++ b/tests/cli_e2e/contact/contact_search_bot_workflow_test.go @@ -29,7 +29,9 @@ func TestContactSearchBotWorkflowAsUser(t *testing.T) { bots := gjson.Get(result.Stdout, "data.bots") require.True(t, bots.IsArray(), "data.bots must be an array; stdout:\n%s", result.Stdout) require.True(t, gjson.Get(result.Stdout, "data.has_more").Exists(), "data.has_more must be present; stdout:\n%s", result.Stdout) - for _, bot := range bots.Array() { + botItems := bots.Array() + require.NotEmpty(t, botItems, "data.bots must contain at least one bot; stdout:\n%s", result.Stdout) + for _, bot := range botItems { require.NotEmpty(t, bot.Get("open_id").String(), "every bot must carry open_id; stdout:\n%s", result.Stdout) } } From 4165af365e91fa33dcb8a055d70beca186202303 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 11:07:33 +0800 Subject: [PATCH 06/27] fix(contact): keep bot description when display_info starts blank parseBotDisplayInfo read the name from the first non-empty line but always took the description from line 1. When line 0 is blank the two collapse: the name is echoed back as its own description and the real description on the next line is dropped. Track which line the name came from and read the description from the line after it. Also document the enable_join_group trap. The flag says a bot may be added to chats, but doing so needs the app's cli_ app_id, which this command does not return -- the ou_ open_id it returns is rejected by the chat-member APIs and there is no open_id to app_id lookup. An agent reading the bare flag will claim a bot was added when it cannot be. Called out in Tips and in the skill doc, and the "open_id is usable downstream" wording is softened to say what the id identifies rather than implying every downstream API accepts it. The command Description listed keyword, chat and chat history as alternatives, which reads as if a chat filter alone is a valid search; --query is required, so the filters are now described as narrowing it. --- shortcuts/contact/contact_search_bot.go | 24 +++++++++++++++----- shortcuts/contact/contact_search_bot_test.go | 4 ++++ skills/lark-contact/SKILL.md | 4 +++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 55245e597e..ae6e795324 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -83,7 +83,7 @@ type searchBotResponse struct { var ContactSearchBot = common.Shortcut{ Service: "contact", Command: "+search-bot", - Description: "Search bots (apps) visible to the calling user by keyword, chat, or chat history (requires --as user)", + Description: "Search bots (apps) visible to the calling user by keyword, optionally narrowed by chat or chat history (requires --as user)", Risk: "read", Scopes: []string{"search:bot"}, AuthTypes: []string{"user"}, @@ -98,7 +98,9 @@ var ContactSearchBot = common.Shortcut{ "Keyword search: lark-cli contact +search-bot --query '会议助手' --as user", "Narrow to bots in a chat: lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user", "Narrow to bots you've chatted with: lark-cli contact +search-bot --query '助手' --has-chatted --as user", - "--query is required; --chat-ids and --has-chatted only narrow it. on has_more=true use --format json to read page_token, then pass --page-token to continue — there is no auto-pagination.", + "--query is required; --chat-ids and --has-chatted only narrow it — a filter-only request returns an empty list, not an error.", + "on has_more=true use --format json to read page_token, then pass --page-token to continue — there is no auto-pagination.", + "enable_join_group=true only means the bot is allowed into chats. Adding it needs the app's cli_ app_id, which this command does not return: the ou_ open_id here is rejected by the chat-member APIs and there is no open_id → app_id lookup. Do not claim a bot was added on the strength of this flag.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateBotSearch(runtime) @@ -308,13 +310,23 @@ func parseBotDisplayInfo(raw, openID string) (name, description string, matchSeg value = strings.ReplaceAll(value, "", "") return strings.TrimSpace(value) } + + // nameLine records which line the name came from, so the description is read + // from the line after it. Reading lines[1] unconditionally echoes the name + // back as its own description whenever line 0 is blank, and drops the real + // description with it. + nameLine := -1 if len(lines) > 0 { - name = stripTags(lines[0]) + if candidate := stripTags(lines[0]); candidate != "" { + name = candidate + nameLine = 0 + } } if name == "" { - for _, line := range lines { + for i, line := range lines { if candidate := stripTags(line); candidate != "" { name = candidate + nameLine = i break } } @@ -322,8 +334,8 @@ func parseBotDisplayInfo(raw, openID string) (name, description string, matchSeg if name == "" { name = openID } - if len(lines) > 1 { - description = stripTags(lines[1]) + if nameLine >= 0 && nameLine+1 < len(lines) { + description = stripTags(lines[nameLine+1]) } return name, description, matchSegments } diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 62475c690c..33be4ae538 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -254,6 +254,10 @@ func TestParseBotDisplayInfo(t *testing.T) { {name: "no newline", raw: "会议助手", openID: "ou_e", wantName: "会议助手", wantSegments: []string{}}, {name: "empty", raw: "", openID: "ou_f", wantName: "ou_f", wantSegments: []string{}}, {name: "fallback line", raw: "\n\n真名", openID: "ou_g", wantName: "真名", wantSegments: []string{}}, + // A blank first line must not make the description echo the name back and + // swallow the real description on the line after it. + {name: "blank first line keeps description", raw: "\n真名\n简介", openID: "ou_h", wantName: "真名", wantDescription: "简介", wantSegments: []string{}}, + {name: "blank first line without description", raw: "\n真名", openID: "ou_i", wantName: "真名", wantSegments: []string{}}, } for _, tt := range tests { diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index 16ee0ba4c3..ee4087864f 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -37,7 +37,7 @@ lark-cli im +messages-send --user-id ou_xxx --text "Hi!" ## 搜索机器人 -`+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人。返回的 `open_id` 是 `ou_` 开头的机器人 open_id,可用于后续操作。`p2p_chat_id` 表示当前用户与机器人的单聊会话,`has_chatted` 表示是否存在该会话。 +`+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人。返回的 `open_id` 是 `ou_` 开头的机器人 open_id,用于标识这个机器人(能不能用于某个下游接口取决于该接口接受的 ID 类型,见下文入群的例子)。`p2p_chat_id` 表示当前用户与机器人的单聊会话,`has_chatted` 表示是否存在该会话。 按关键词搜索: @@ -61,6 +61,8 @@ lark-cli contact +search-bot --query '助手' --page-token cursor_xxx --format j `--format pretty` 使用六列摘要;`table`、`csv` 和 `ndjson` 与 `+search-user` 一样使用完整结果字段。 +`enable_join_group=true` 只表示该机器人允许被拉进群聊,**不代表你能用这里的 `open_id` 把它拉进群**。把机器人加入群聊需要应用的 `cli_` 开头 app_id,本命令不返回;直接用 `ou_` 开头的 open_id 调加群接口会被服务端放进 `invalid_id_list`,且没有 open_id 到 app_id 的查询接口。看到这个字段为真时,不要据此声称已把机器人加入群聊。 + ## 注意事项 - **41050 / Permission denied** 按命令处理:`+search-user` 只支持 user 身份,重新授权 `contact:user:search`;`+search-bot` 只支持 user 身份,重新授权 `search:bot`;`+get-user` 同时支持 user 和 bot,可改用具备对应通讯录权限的身份。身份与授权细节见 [`lark-shared`](../lark-shared/SKILL.md)。 From 9326300f3c56ed9613e286aa848e37dfddb2ccd9 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 11:35:40 +0800 Subject: [PATCH 07/27] docs(contact): state why bot search pins open_id Both contact/v3/users/search and bot/v4/bot/search accept user_id_type, and union_id does come back as on_... on either. Neither +search-user nor +search-bot exposes it, because both project the response into a fixed struct whose field is named open_id -- switching the type would leave that field holding a union_id or an employee id, so the name would lie. +get-user can offer --user-id-type precisely because it passes the raw response through. Record the rule and the escape hatch so the omission reads as a decision rather than an oversight, and so the next reader does not add the flag without also renaming the field. --- skills/lark-contact/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index ee4087864f..3f3bbb467a 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -67,7 +67,7 @@ lark-cli contact +search-bot --query '助手' --page-token cursor_xxx --format j - **41050 / Permission denied** 按命令处理:`+search-user` 只支持 user 身份,重新授权 `contact:user:search`;`+search-bot` 只支持 user 身份,重新授权 `search:bot`;`+get-user` 同时支持 user 和 bot,可改用具备对应通讯录权限的身份。身份与授权细节见 [`lark-shared`](../lark-shared/SKILL.md)。 - **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。 -- **ID 类型**:默认 `open_id`。`+get-user` 可改 `--user-id-type union_id|user_id`;`+search-user` 只接受 `open_id`。 +- **ID 类型**:默认 `open_id`。`+get-user` 原样透传服务端响应,可改 `--user-id-type union_id|user_id`;`+search-user` 和 `+search-bot` 有固定的输出结构,一律只出 `open_id`,不接受切换(两个接口本身支持 `user_id_type`,但字段名会随之说谎,所以 CLI 不暴露;确实要 union_id / user_id 时走 `lark-cli api` 直调)。 ## 不在本 skill 范围 From c1bf704d6e1b254a4fb5865515ea5edcaede302d Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 11:45:22 +0800 Subject: [PATCH 08/27] refactor(contact): follow search-user flag idioms in bot search Two spots diverged from the sibling command for no reason. parseBotSearchChatIDs gated on Cmd.Flags().Changed("chat-ids"). +search-user never does that for a string flag -- it tests emptiness (strings.TrimSpace(runtime.Str(...)) != "") and reserves Changed() for bool filters, where emptiness cannot distinguish unset from false. The practical difference is that --chat-ids "" used to be an error here and a no-op there; now a blank value reads as "no filter" in both, and only a non-blank value that parses to zero entries is rejected. Tests pin both halves. botSearchPagination existed only to return two runtime lookups to two callers. +search-user builds its query params inline in DryRun and Execute, so do the same and keep the opaque-page-token note where the token is actually read. --- shortcuts/contact/contact_search_bot.go | 21 ++++++++------------ shortcuts/contact/contact_search_bot_test.go | 7 +++++++ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index ae6e795324..229bbb279a 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -110,9 +110,10 @@ var ContactSearchBot = common.Shortcut{ if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } - pageSize, pageToken := botSearchPagination(runtime) - params := map[string]interface{}{"page_size": pageSize} - if pageToken != "" { + params := map[string]interface{}{"page_size": runtime.Int("page-size")} + // Page tokens are opaque server values: pass them through verbatim, and + // never send an empty one. + if pageToken := runtime.Str("page-token"); pageToken != "" { params["page_token"] = pageToken } return common.NewDryRunAPI().POST(botSearchURL).Params(params).Body(body) @@ -153,16 +154,11 @@ func validateBotSearch(runtime *common.RuntimeContext) error { return nil } -func botSearchPagination(runtime *common.RuntimeContext) (int, string) { - // Page tokens are opaque server values; preserve them verbatim. - return runtime.Int("page-size"), runtime.Str("page-token") -} - func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) { - if !runtime.Cmd.Flags().Changed("chat-ids") { + raw := strings.TrimSpace(runtime.Str("chat-ids")) + if raw == "" { return nil, nil } - raw := strings.TrimSpace(runtime.Str("chat-ids")) chatIDs := common.SplitCSV(raw) if len(chatIDs) == 0 { return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw). @@ -212,11 +208,10 @@ func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error return err } - pageSize, pageToken := botSearchPagination(runtime) queryParams := larkcore.QueryParams{ - "page_size": []string{strconv.Itoa(pageSize)}, + "page_size": []string{strconv.Itoa(runtime.Int("page-size"))}, } - if pageToken != "" { + if pageToken := runtime.Str("page-token"); pageToken != "" { queryParams["page_token"] = []string{pageToken} } diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 33be4ae538..6ebb143c09 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -162,6 +162,11 @@ func TestValidateBotSearchPassingCases(t *testing.T) { {name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}}, {name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}}, {name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "30"}}, + // An explicitly blank string flag reads as "no filter", matching how + // +search-user treats --user-ids / --queries. Only a non-blank value that + // parses to zero entries is an error. + {name: "blank chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": ""}}, + {name: "whitespace chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": " "}}, } for _, tt := range tests { @@ -214,6 +219,8 @@ func TestBuildBotSearchBody(t *testing.T) { {name: "chat id URL normalized", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`}, {name: "has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"has_chatter":true}}`}, {name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`}, + // A blank --chat-ids must not materialize an empty filter object. + {name: "blank chat ids omit filter", flags: map[string]string{"query": "x", "chat-ids": " "}, wantJSON: `{"query":"x"}`}, } for _, tt := range tests { From d48aab288977ea2e3ea3d22c5698f6fce8342c36 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 11:52:28 +0800 Subject: [PATCH 09/27] refactor(contact): drop bot search pagination to match search-user +search-user decodes page_token off the wire and deliberately never surfaces it: no --page-token flag, no page_token in searchUserResponse, and a has_more hint that tells the caller to refine instead ("add filters or tighten --query"). Bot search had grown the opposite shape -- a --page-token flag, the token in the envelope, and a hint pointing at it -- so the two sibling search commands disagreed on whether pagination exists. Align on the sibling: keep decoding page_token so the field is not silently lost from the API type, but stop exposing it, and reword both the tip and the stderr hint to name the narrowing options this command actually has (--chat-ids, --has-chatted, a more specific --query). TestBotSearchIntegrationEmptyPageTokenOmitted becomes TestBotSearchIntegrationNeverSurfacesPageToken and now has the stub return a token, so the test fails if the field is ever re-added to the envelope rather than only covering the empty case. --- shortcuts/contact/contact_search_bot.go | 42 +++++++------------ shortcuts/contact/contact_search_bot_test.go | 25 +++++------ skills/lark-contact/SKILL.md | 7 +--- .../dryrun/contact_search_bot_dryrun_test.go | 2 - 4 files changed, 30 insertions(+), 46 deletions(-) diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 229bbb279a..6ac83b094f 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -73,11 +73,13 @@ type searchBot struct { MatchSegments []string `json:"match_segments"` } +// PageToken is decoded from the response but deliberately not surfaced, matching +// searchUserResponse: neither search command paginates. Callers narrow the query +// instead, so handing out a token that no flag accepts would only mislead. type searchBotResponse struct { - Bots []searchBot `json:"bots"` - HasMore bool `json:"has_more"` - PageToken string `json:"page_token,omitempty"` - Notice string `json:"notice,omitempty"` + Bots []searchBot `json:"bots"` + HasMore bool `json:"has_more"` + Notice string `json:"notice,omitempty"` } var ContactSearchBot = common.Shortcut{ @@ -92,14 +94,13 @@ var ContactSearchBot = common.Shortcut{ {Name: "chat-ids", Desc: "narrow --query to bots in these chats (CSV of chat_id; ≤ 100)"}, {Name: "has-chatted", Type: "bool", Desc: "narrow --query to bots you've chatted with (omit to disable; =false rejected)"}, {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"}, - {Name: "page-token", Desc: "pagination token from a previous response"}, }, Tips: []string{ "Keyword search: lark-cli contact +search-bot --query '会议助手' --as user", "Narrow to bots in a chat: lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user", "Narrow to bots you've chatted with: lark-cli contact +search-bot --query '助手' --has-chatted --as user", "--query is required; --chat-ids and --has-chatted only narrow it — a filter-only request returns an empty list, not an error.", - "on has_more=true use --format json to read page_token, then pass --page-token to continue — there is no auto-pagination.", + "on has_more=true narrow the search (add --chat-ids or --has-chatted, or use a more specific --query) — there is no pagination.", "enable_join_group=true only means the bot is allowed into chats. Adding it needs the app's cli_ app_id, which this command does not return: the ou_ open_id here is rejected by the chat-member APIs and there is no open_id → app_id lookup. Do not claim a bot was added on the strength of this flag.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -110,13 +111,10 @@ var ContactSearchBot = common.Shortcut{ if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } - params := map[string]interface{}{"page_size": runtime.Int("page-size")} - // Page tokens are opaque server values: pass them through verbatim, and - // never send an empty one. - if pageToken := runtime.Str("page-token"); pageToken != "" { - params["page_token"] = pageToken - } - return common.NewDryRunAPI().POST(botSearchURL).Params(params).Body(body) + return common.NewDryRunAPI(). + POST(botSearchURL). + Params(map[string]interface{}{"page_size": runtime.Int("page-size")}). + Body(body) }, Execute: executeBotSearch, } @@ -208,18 +206,11 @@ func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error return err } - queryParams := larkcore.QueryParams{ - "page_size": []string{strconv.Itoa(runtime.Int("page-size"))}, - } - if pageToken := runtime.Str("page-token"); pageToken != "" { - queryParams["page_token"] = []string{pageToken} - } - apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodPost, ApiPath: botSearchURL, Body: body, - QueryParams: queryParams, + QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}}, }) if err != nil { return err @@ -236,10 +227,9 @@ func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error bots := projectBots(respData) out := searchBotResponse{ - Bots: bots, - HasMore: respData.HasMore, - PageToken: respData.PageToken, - Notice: respData.Notice, + Bots: bots, + HasMore: respData.HasMore, + Notice: respData.Notice, } runtime.OutFormat(out, &output.Meta{Count: len(bots)}, func(w io.Writer) { if len(bots) == 0 { @@ -250,7 +240,7 @@ func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error }) if respData.HasMore && isHumanReadableFormat(runtime.Format) { fmt.Fprintln(runtime.IO().ErrOut, - "\nhint: more matches exist; use --format json to read page_token, then pass --page-token to continue") + "\nhint: more matches exist; narrow the search (e.g. add --chat-ids, --has-chatted, or a more specific --query)") } return nil } diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 6ebb143c09..6da20a9e11 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -24,7 +24,6 @@ func newBotSearchTestCommand() *cobra.Command { cmd.Flags().String("chat-ids", "", "") cmd.Flags().Bool("has-chatted", false, "") cmd.Flags().Int("page-size", 20, "") - cmd.Flags().String("page-token", "", "") return cmd } @@ -363,12 +362,12 @@ func botSearchStub(url string, pageToken string) *httpmock.Stub { func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) { factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) - stub := botSearchStub(botSearchURL+"?page_size=25&page_token=cursor_in", "cursor_out") + stub := botSearchStub(botSearchURL+"?page_size=25", "cursor_out") registry.Register(stub) err := mountAndRun(t, ContactSearchBot, []string{ "+search-bot", "--query", "助手", "--chat-ids", "oc_a,oc_b", "--has-chatted", - "--page-size", "25", "--page-token", "cursor_in", "--format", "json", "--as", "user", + "--page-size", "25", "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("execute: %v", err) @@ -392,7 +391,7 @@ func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) { if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { t.Fatalf("response JSON: %v\n%s", err, stdout.String()) } - if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || envelope.Data.PageToken != "cursor_out" || !envelope.Data.HasMore { + if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || !envelope.Data.HasMore { t.Fatalf("response pass-through: %+v", envelope.Data) } if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].P2PChatID != "oc_p2p" { @@ -401,9 +400,11 @@ func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) { registry.Verify(t) } -func TestBotSearchIntegrationEmptyPageTokenOmitted(t *testing.T) { +func TestBotSearchIntegrationNeverSurfacesPageToken(t *testing.T) { factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) - registry.Register(botSearchStub(botSearchURL+"?page_size=20", "")) + // The stub returns a token; the envelope must still not carry one, matching + // +search-user, which decodes page_token and drops it. + registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out")) err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "助手", "--format", "json", "--as", "user"}, factory, stdout) if err != nil { @@ -415,7 +416,7 @@ func TestBotSearchIntegrationEmptyPageTokenOmitted(t *testing.T) { } data := envelope["data"].(map[string]interface{}) if _, ok := data["page_token"]; ok { - t.Fatalf("empty page_token must be omitted: %v", data) + t.Fatalf("page_token must never be surfaced: %v", data) } } @@ -432,12 +433,12 @@ func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) { t.Errorf("pretty output missing %q: %s", column, stdout.String()) } } - for _, genericField := range []string{"bots", "has_more", "page_token", "notice", "tenant_id", "p2p_chat_id", "match_segments"} { + for _, genericField := range []string{"bots", "has_more", "notice", "tenant_id", "p2p_chat_id", "match_segments"} { if strings.Contains(stdout.String(), genericField) { t.Errorf("pretty output exposed %q: %s", genericField, stdout.String()) } } - wantHint := "\nhint: more matches exist; use --format json to read page_token, then pass --page-token to continue\n" + wantHint := "\nhint: more matches exist; narrow the search (e.g. add --chat-ids, --has-chatted, or a more specific --query)\n" if stderr.String() != wantHint { t.Fatalf("pretty stderr: got %q, want %q", stderr.String(), wantHint) } @@ -456,7 +457,7 @@ func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) { t.Errorf("table output missing %q: %s", field, stdout.String()) } } - wantHint := "\nhint: more matches exist; use --format json to read page_token, then pass --page-token to continue\n" + wantHint := "\nhint: more matches exist; narrow the search (e.g. add --chat-ids, --has-chatted, or a more specific --query)\n" if stderr.String() != wantHint { t.Fatalf("table stderr: got %q, want %q", stderr.String(), wantHint) } @@ -508,7 +509,7 @@ func TestBotSearchDryRunMirrorsRequest(t *testing.T) { factory, stdout, _, _ := cmdutil.TestFactory(t, botSearchDefaultConfig()) err := mountAndRun(t, ContactSearchBot, []string{ "+search-bot", "--query", "助手", "--chat-ids", "oc_a", "--has-chatted", - "--page-size", "25", "--page-token", " cursor ", "--dry-run", "--as", "user", + "--page-size", "25", "--dry-run", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("execute: %v", err) @@ -530,7 +531,7 @@ func TestBotSearchDryRunMirrorsRequest(t *testing.T) { t.Fatalf("api calls: got %d, want 1", len(envelope.Data.API)) } call := envelope.Data.API[0] - if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) || call.Params["page_token"] != " cursor " { + if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) { t.Fatalf("dry-run call: %+v", call) } if call.Body.Query != "助手" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter { diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index 3f3bbb467a..39e14ef82a 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -52,12 +52,7 @@ lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user lark-cli contact +search-bot --query '助手' --has-chatted --as user ``` -返回 `has_more=true` 时,使用 JSON 格式读取 `page_token`,再传给下一次请求。该命令不会自动获取后续页面: - -```bash -lark-cli contact +search-bot --query '助手' --format json --as user -lark-cli contact +search-bot --query '助手' --page-token cursor_xxx --format json --as user -``` +返回 `has_more=true` 表示还有更多命中,但和 `+search-user` 一样**没有分页**:收窄搜索条件(补 `--chat-ids` 或 `--has-chatted`,或换更具体的 `--query`),而不是翻页。 `--format pretty` 使用六列摘要;`table`、`csv` 和 `ndjson` 与 `+search-user` 一样使用完整结果字段。 diff --git a/tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go b/tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go index 8cdb3a3642..c94c75f43a 100644 --- a/tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go +++ b/tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go @@ -28,7 +28,6 @@ func TestContactSearchBotDryRun(t *testing.T) { "--chat-ids", "oc_a,oc_b", "--has-chatted", "--page-size", "25", - "--page-token", "cursor_in", "--dry-run", }, DefaultAs: "user", @@ -40,7 +39,6 @@ func TestContactSearchBotDryRun(t *testing.T) { require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out) require.Equal(t, "/open-apis/bot/v4/bot/search", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out) require.Equal(t, int64(25), clie2e.DryRunGet(out, "api.0.params.page_size").Int(), "stdout:\n%s", out) - require.Equal(t, "cursor_in", clie2e.DryRunGet(out, "api.0.params.page_token").String(), "stdout:\n%s", out) require.Equal(t, "助手", clie2e.DryRunGet(out, "api.0.body.query").String(), "stdout:\n%s", out) require.Equal(t, []string{"oc_a", "oc_b"}, []string{ clie2e.DryRunGet(out, "api.0.body.filter.chat_ids.0").String(), From 043c016562cf775e6366a3b7c28aa2275346dd56 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 11:57:51 +0800 Subject: [PATCH 10/27] feat(contact): add multi-keyword fanout to bot search +search-user has --queries: comma-separated keywords searched in parallel, a flat users[] where every row carries matched_query, and a queries[] sidecar holding each keyword's has_more and notice. Bot search had no equivalent, which hurts more here than there because --query is mandatory, so resolving three bot names meant three sequential invocations. Mirror the sibling rather than invent a second mechanism: contact_search_bot_ fanout.go reuses parseAndDedupQueries, querySummary, fanoutConcurrency, isFanoutSummaryFormat and the contactFanout* error helpers, and repeats its structure -- one worker per keyword behind the same concurrency cap, panic recovery per worker, results reindexed into query order, and failure only when every keyword fails so a single bad keyword cannot sink the batch. --chat-ids and --has-chatted narrow every keyword in the batch, matching how the bool filters apply across the user fanout. --query and --queries are mutually exclusive, and the "--query is required" rule is scoped to single-search mode so it does not leak into fanout. --- shortcuts/contact/contact_search_bot.go | 69 ++++- .../contact/contact_search_bot_fanout.go | 244 +++++++++++++++ .../contact/contact_search_bot_fanout_test.go | 281 ++++++++++++++++++ shortcuts/contact/contact_search_bot_test.go | 1 + skills/lark-contact/SKILL.md | 8 + 5 files changed, 596 insertions(+), 7 deletions(-) create mode 100644 shortcuts/contact/contact_search_bot_fanout.go create mode 100644 shortcuts/contact/contact_search_bot_fanout_test.go diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 6ac83b094f..e06cd18e95 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -14,6 +14,7 @@ import ( "strings" "unicode/utf8" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -94,11 +95,13 @@ var ContactSearchBot = common.Shortcut{ {Name: "chat-ids", Desc: "narrow --query to bots in these chats (CSV of chat_id; ≤ 100)"}, {Name: "has-chatted", Type: "bool", Desc: "narrow --query to bots you've chatted with (omit to disable; =false rejected)"}, {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"}, + {Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"}, }, Tips: []string{ "Keyword search: lark-cli contact +search-bot --query '会议助手' --as user", "Narrow to bots in a chat: lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user", "Narrow to bots you've chatted with: lark-cli contact +search-bot --query '助手' --has-chatted --as user", + "Multi-name fanout: lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user", "--query is required; --chat-ids and --has-chatted only narrow it — a filter-only request returns an empty list, not an error.", "on has_more=true narrow the search (add --chat-ids or --has-chatted, or use a more specific --query) — there is no pagination.", "enable_join_group=true only means the bot is allowed into chats. Adding it needs the app's cli_ app_id, which this command does not return: the ou_ open_id here is rejected by the chat-member APIs and there is no open_id → app_id lookup. Do not claim a bot was added on the strength of this flag.", @@ -107,6 +110,23 @@ var ContactSearchBot = common.Shortcut{ return validateBotSearch(runtime) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" { + filter, err := buildBotFanoutFilter(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + api := common.NewDryRunAPI() + for _, q := range parseAndDedupQueries(raw) { + body := &botSearchAPIRequest{Query: q} + if filter != nil { + body.Filter = filter + } + api.POST(botSearchURL). + Params(map[string]interface{}{"page_size": runtime.Int("page-size")}). + Body(body) + } + return api + } body, err := buildBotSearchBody(runtime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) @@ -119,19 +139,54 @@ var ContactSearchBot = common.Shortcut{ Execute: executeBotSearch, } +// executeBotSearch dispatches to single-query or fanout mode. +func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("queries")) != "" { + return executeBotSearchFanout(ctx, runtime) + } + return executeBotSearchSingle(ctx, runtime) +} + func botSearchQueryRequiredError() error { return common.ValidationErrorf("--query is required: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API returns an empty list for filter-only requests)"). WithParam("--query") } func validateBotSearch(runtime *common.RuntimeContext) error { + queriesRaw := strings.TrimSpace(runtime.Str("queries")) query := strings.TrimSpace(runtime.Str("query")) - if query == "" { - return botSearchQueryRequiredError() - } - if utf8.RuneCountInString(query) > maxBotSearchQueryChars { - return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars). - WithParam("--query") + + if queriesRaw != "" { + if query != "" { + return common.ValidationErrorf("--query and --queries are mutually exclusive"). + WithParams( + errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"}, + errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"}, + ) + } + queries := parseAndDedupQueries(queriesRaw) + if len(queries) == 0 { + return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw). + WithParam("--queries") + } + if len(queries) > maxFanoutQueries { + return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)). + WithParam("--queries") + } + for _, q := range queries { + if utf8.RuneCountInString(q) > maxBotSearchQueryChars { + return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxBotSearchQueryChars). + WithParam("--queries") + } + } + } else { + if query == "" { + return botSearchQueryRequiredError() + } + if utf8.RuneCountInString(query) > maxBotSearchQueryChars { + return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars). + WithParam("--query") + } } if _, err := parseBotSearchChatIDs(runtime); err != nil { @@ -200,7 +255,7 @@ func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, e return req, nil } -func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error { +func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) error { body, err := buildBotSearchBody(runtime) if err != nil { return err diff --git a/shortcuts/contact/contact_search_bot_fanout.go b/shortcuts/contact/contact_search_bot_fanout.go new file mode 100644 index 0000000000..a27c6d5f7f --- /dev/null +++ b/shortcuts/contact/contact_search_bot_fanout.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "sync" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// Bot fanout mirrors the user fanout in contact_search_user_fanout.go: same +// dedup, same concurrency cap, same per-query summary, same "fail only when every +// query fails" rule. It reuses parseAndDedupQueries, querySummary, +// isFanoutSummaryFormat and the contactFanout* error helpers rather than growing +// a second set. + +type botFanoutResult struct { + Index int + Query string + Bots []searchBot + HasMore bool + Notice string + ErrMsg string // empty = success + Err error // original failure, kept for typed all-failed propagation +} + +// runOneBotQuery converts one fanout request into either bots or an error summary. +func runOneBotQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string, + filter *botSearchAPIFilter) botFanoutResult { + // Pre-check ctx so queued workers see cancellation before issuing a request; + // in-flight workers continue until DoAPI returns. + if err := ctx.Err(); err != nil { + return botFanoutErrorResult(index, query, err) + } + + body := &botSearchAPIRequest{Query: query} + if filter != nil { + body.Filter = filter + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: botSearchURL, + Body: body, + QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}}, + }) + if err != nil { + return botFanoutErrorResult(index, query, err) + } + + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return botFanoutErrorResult(index, query, err) + } + respData, err := decodeBotSearchAPIData(data) + if err != nil { + return botFanoutErrorResult(index, query, err) + } + + return botFanoutResult{ + Index: index, + Query: query, + Bots: projectBots(respData), + HasMore: respData.HasMore, + Notice: respData.Notice, + } +} + +// botFanoutErrorResult records a failed fanout query without stopping other workers. +func botFanoutErrorResult(index int, query string, err error) botFanoutResult { + if err == nil { + return botFanoutResult{Index: index, Query: query} + } + return botFanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err} +} + +type fanoutBot struct { + searchBot + MatchedQuery string `json:"matched_query"` +} + +type botFanoutResponse struct { + Bots []fanoutBot `json:"bots"` + Queries []querySummary `json:"queries"` + Notice string `json:"notice,omitempty"` +} + +// buildBotFanoutResponse flattens ordered fanout results and fails only when all +// queries fail. +func buildBotFanoutResponse(queries []string, results []botFanoutResult) (*botFanoutResponse, error) { + indexed := make([]botFanoutResult, len(queries)) + for _, r := range results { + indexed[r.Index] = r + } + + out := &botFanoutResponse{ + Bots: make([]fanoutBot, 0), + Queries: make([]querySummary, 0, len(queries)), + } + failed := 0 + var firstErrMsg, firstErrQuery string + var firstErr error + for i, r := range indexed { + out.Queries = append(out.Queries, querySummary{ + Query: queries[i], + Error: r.ErrMsg, + HasMore: r.HasMore, + Notice: r.Notice, + }) + if r.ErrMsg != "" { + failed++ + if firstErrMsg == "" { + firstErrMsg = r.ErrMsg + firstErrQuery = queries[i] + firstErr = r.Err + } + continue + } + if out.Notice == "" { + out.Notice = r.Notice + } + for _, b := range r.Bots { + out.Bots = append(out.Bots, fanoutBot{searchBot: b, MatchedQuery: queries[i]}) + } + } + if failed == len(queries) && len(queries) > 0 { + msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)", + len(queries), firstErrMsg, firstErrQuery) + return nil, contactFanoutAllFailedError(firstErr, msg) + } + return out, nil +} + +func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) error { + queries := parseAndDedupQueries(runtime.Str("queries")) + + filter, err := buildBotFanoutFilter(runtime) + if err != nil { + return err + } + + results := make([]botFanoutResult, len(queries)) + var wg sync.WaitGroup + sem := make(chan struct{}, fanoutConcurrency) + + for i, q := range queries { + wg.Add(1) + sem <- struct{}{} + go func(i int, q string) { + defer wg.Done() + defer func() { <-sem }() + defer func() { + if r := recover(); r != nil { + results[i] = botFanoutResult{ + Index: i, + Query: q, + ErrMsg: fmt.Sprintf("internal error: %v", r), + } + } + }() + results[i] = runOneBotQuery(ctx, runtime, i, q, filter) + }(i, q) + } + wg.Wait() + + resp, err := buildBotFanoutResponse(queries, results) + if err != nil { + return err + } + + failed, hasMoreCount := 0, 0 + for _, qs := range resp.Queries { + if qs.Error != "" { + failed++ + } + if qs.HasMore { + hasMoreCount++ + } + } + + runtime.OutFormat(resp, &output.Meta{Count: len(resp.Bots)}, func(w io.Writer) { + if len(resp.Bots) == 0 { + fmt.Fprintln(w, "No bots found.") + return + } + output.PrintTable(w, prettyBotFanoutRows(resp.Bots)) + }) + + if isFanoutSummaryFormat(runtime.Format) { + fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total bots; %d failed, %d with has_more\n", + len(queries), len(resp.Bots), failed, hasMoreCount) + } + return nil +} + +// buildBotFanoutFilter reuses the single-search filter: --chat-ids and +// --has-chatted narrow every query in the fanout, exactly as the bool filters do +// for the user fanout. +func buildBotFanoutFilter(runtime *common.RuntimeContext) (*botSearchAPIFilter, error) { + filter := &botSearchAPIFilter{} + hasFilter := false + + chatIDs, err := parseBotSearchChatIDs(runtime) + if err != nil { + return nil, err + } + if len(chatIDs) > 0 { + filter.ChatIDs = chatIDs + hasFilter = true + } + if runtime.Cmd.Flags().Changed("has-chatted") && runtime.Bool("has-chatted") { + filter.HasChatter = true + hasFilter = true + } + + if !hasFilter { + return nil, nil + } + return filter, nil +} + +func prettyBotFanoutRows(bots []fanoutBot) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(bots)) + for _, bot := range bots { + rows = append(rows, map[string]interface{}{ + "matched_query": bot.MatchedQuery, + "name": bot.Name, + "description": common.TruncateStr(bot.Description, 50), + "has_chatted": bot.HasChatted, + "is_agent": bot.IsAgent, + "enable_join_group": bot.EnableJoinGroup, + "open_id": bot.OpenID, + }) + } + return rows +} diff --git a/shortcuts/contact/contact_search_bot_fanout_test.go b/shortcuts/contact/contact_search_bot_fanout_test.go new file mode 100644 index 0000000000..61e86929ae --- /dev/null +++ b/shortcuts/contact/contact_search_bot_fanout_test.go @@ -0,0 +1,281 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestBotFanoutErrorResultNilErrorIsSuccess(t *testing.T) { + r := botFanoutErrorResult(3, "会议助手", nil) + if r.ErrMsg != "" || r.Err != nil { + t.Fatalf("nil error must stay a success result: %+v", r) + } + if r.Index != 3 || r.Query != "会议助手" { + t.Fatalf("index/query must survive: %+v", r) + } +} + +func TestBotFanoutAssembleOrderAndShape(t *testing.T) { + results := []botFanoutResult{ + {Index: 1, Query: "日报", Bots: []searchBot{{OpenID: "ou_b"}}, HasMore: true}, + {Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}}, + {Index: 2, Query: "审批", ErrMsg: "API 1: nope"}, + } + resp, err := buildBotFanoutResponse([]string{"会议", "日报", "审批"}, results) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Results are emitted in query order even though the workers finished out of + // order, and a failed query contributes no rows. + wantRows := []struct { + openID, matched string + }{{"ou_a1", "会议"}, {"ou_a2", "会议"}, {"ou_b", "日报"}} + if len(resp.Bots) != len(wantRows) { + t.Fatalf("bots length: got %d, want %d", len(resp.Bots), len(wantRows)) + } + for i, w := range wantRows { + if resp.Bots[i].OpenID != w.openID || resp.Bots[i].MatchedQuery != w.matched { + t.Errorf("bots[%d]: got %+v, want %s/%s", i, resp.Bots[i], w.openID, w.matched) + } + } + + want := []querySummary{ + {Query: "会议"}, + {Query: "日报", HasMore: true}, + {Query: "审批", Error: "API 1: nope"}, + } + if len(resp.Queries) != len(want) { + t.Fatalf("queries length: got %d, want %d (every query is enumerated)", len(resp.Queries), len(want)) + } + for i, w := range want { + if resp.Queries[i] != w { + t.Errorf("queries[%d]: got %+v, want %+v", i, resp.Queries[i], w) + } + } +} + +func TestBotFanoutAssembleAllFailedReturnsTypedError(t *testing.T) { + results := []botFanoutResult{ + {Index: 0, Query: "会议", ErrMsg: "API 99991663: rate limit", Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663)}, + {Index: 1, Query: "日报", ErrMsg: "HTTP 500 Internal Server Error"}, + } + _, err := buildBotFanoutResponse([]string{"会议", "日报"}, results) + if err == nil { + t.Fatal("expected an error when every query fails") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed problem, got %T: %v", err, err) + } + // The first failure's classification must survive, so the caller can tell a + // rate limit apart from a transport fault. + if problem.Code != 99991663 || problem.Subtype != errs.SubtypeRateLimit { + t.Errorf("problem: got %d/%s, want 99991663/%s", problem.Code, problem.Subtype, errs.SubtypeRateLimit) + } + // Agents grep the count and the first failure out of this message. + for _, want := range []string{"all 2 queries failed", "rate limit"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("message must contain %q; got %v", want, err) + } + } +} + +func TestBotFanoutAssemblePartialFailureSucceeds(t *testing.T) { + results := []botFanoutResult{ + {Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}}, + {Index: 1, Query: "日报", ErrMsg: "API 1: nope"}, + } + resp, err := buildBotFanoutResponse([]string{"会议", "日报"}, results) + if err != nil { + t.Fatalf("one failure out of two must not fail the call: %v", err) + } + if len(resp.Bots) != 1 || resp.Queries[1].Error == "" { + t.Fatalf("partial failure shape: %+v", resp) + } +} + +func TestBotFanoutResponseHasNoTopLevelHasMore(t *testing.T) { + resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议", HasMore: true}}) + if err != nil { + t.Fatalf("build: %v", err) + } + raw, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var envelope map[string]interface{} + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // has_more is per query in the sidecar; a single top-level flag would hide + // which keyword was truncated. + if _, ok := envelope["has_more"]; ok { + t.Fatalf("fanout must not surface a top-level has_more: %s", raw) + } + if !envelope["queries"].([]interface{})[0].(map[string]interface{})["has_more"].(bool) { + t.Fatalf("per-query has_more lost: %s", raw) + } +} + +func TestBotFanoutEmptyBotsSerializesAsArray(t *testing.T) { + resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议"}}) + if err != nil { + t.Fatalf("build: %v", err) + } + raw, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(raw), `"bots":[]`) { + t.Fatalf("empty bots must serialize as [], not null: %s", raw) + } +} + +func TestPrettyBotFanoutRowsLeadWithMatchedQuery(t *testing.T) { + rows := prettyBotFanoutRows([]fanoutBot{{ + searchBot: searchBot{OpenID: "ou_a", Name: "会议助手", Description: strings.Repeat("长", 80), HasChatted: true}, + MatchedQuery: "会议", + }}) + if len(rows) != 1 { + t.Fatalf("rows: %d", len(rows)) + } + if rows[0]["matched_query"] != "会议" { + t.Errorf("matched_query missing: %+v", rows[0]) + } + if got := rows[0]["description"].(string); len([]rune(got)) > 51 { + t.Errorf("description must be truncated like the single-search table: %d runes", len([]rune(got))) + } +} + +func TestBotFanoutValidationRejectsQueryAndQueriesTogether(t *testing.T) { + cmd := newBotSearchTestCommand() + setBotSearchFlag(t, cmd, "query", "会议") + setBotSearchFlag(t, cmd, "queries", "会议,日报") + runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig()) + + err := validateBotSearch(runtime) + if err == nil { + t.Fatal("expected mutual-exclusion error") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem: %+v ok=%v", problem, ok) + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("message: %v", err) + } +} + +func TestBotFanoutValidationLimits(t *testing.T) { + tests := []struct { + name string + queries string + wantParam string + }{ + {name: "nothing parses", queries: " , , ", wantParam: "--queries"}, + {name: "over the entry cap", queries: strings.TrimSuffix(strings.Repeat("q%d,", maxFanoutQueries+1), ","), wantParam: "--queries"}, + {name: "entry too long", queries: strings.Repeat("会", maxBotSearchQueryChars+1), wantParam: "--queries"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + queries := tt.queries + if strings.Contains(queries, "%d") { + parts := make([]string, 0, maxFanoutQueries+1) + for i := 0; i <= maxFanoutQueries; i++ { + parts = append(parts, fmt.Sprintf("q%d", i)) + } + queries = strings.Join(parts, ",") + } + cmd := newBotSearchTestCommand() + setBotSearchFlag(t, cmd, "queries", queries) + runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig()) + assertBotSearchValidationProblem(t, validateBotSearch(runtime), tt.wantParam) + }) + } +} + +// --queries alone is enough: the single-search "--query is required" rule must not +// leak into fanout mode. +func TestBotFanoutValidationQueriesAloneIsValid(t *testing.T) { + cmd := newBotSearchTestCommand() + setBotSearchFlag(t, cmd, "queries", "会议助手,日报助手") + runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig()) + if err := validateBotSearch(runtime); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBotFanoutFilterAppliedToEveryQuery(t *testing.T) { + factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + stub := botSearchStub(botSearchURL+"?page_size=20", "") + stub.Reusable = true + registry.Register(stub) + + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--queries", "会议,日报", "--has-chatted", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(stub.CapturedBodies) != 2 { + t.Fatalf("expected one request per query, got %d", len(stub.CapturedBodies)) + } + seen := make(map[string]bool, len(stub.CapturedBodies)) + for i, raw := range stub.CapturedBodies { + var body map[string]interface{} + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("unmarshal req %d: %v", i, err) + } + seen[fmt.Sprint(body["query"])] = true + filter, ok := body["filter"].(map[string]interface{}) + if !ok || filter["has_chatter"] != true { + t.Fatalf("filter must ride along with every query: %#v", body) + } + } + for _, q := range []string{"会议", "日报"} { + if !seen[q] { + t.Fatalf("query %q never issued; saw %v", q, seen) + } + } +} + +func TestBotFanoutMatchedQueryFidelityAndDedup(t *testing.T) { + factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + dedupStub := botSearchStub(botSearchURL+"?page_size=20", "") + dedupStub.Reusable = true + registry.Register(dedupStub) + + // " 会议 " and "会议" collapse to one query; the duplicate must not double the + // requests or the rows. + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--queries", " 会议 ,会议", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + + var envelope struct { + Data botFanoutResponse `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("response JSON: %v\n%s", err, stdout.String()) + } + if len(envelope.Data.Queries) != 1 || envelope.Data.Queries[0].Query != "会议" { + t.Fatalf("dedup failed: %+v", envelope.Data.Queries) + } + for _, bot := range envelope.Data.Bots { + if bot.MatchedQuery != "会议" { + t.Fatalf("matched_query fidelity: %+v", bot) + } + } +} diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 6da20a9e11..5ad49502de 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -24,6 +24,7 @@ func newBotSearchTestCommand() *cobra.Command { cmd.Flags().String("chat-ids", "", "") cmd.Flags().Bool("has-chatted", false, "") cmd.Flags().Int("page-size", 20, "") + cmd.Flags().String("queries", "", "") return cmd } diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index 39e14ef82a..ccd61143d2 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -52,6 +52,14 @@ lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user lark-cli contact +search-bot --query '助手' --has-chatted --as user ``` +一次要找多个机器人时用 `--queries`(和 `--query` 互斥),逗号分隔、并行搜、最多 20 个词: + +```bash +lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user +``` + +输出是扁平的 `bots[]`,每行多一个 `matched_query` 说明是哪个词命中的;另有 `queries[]` 汇总逐词的 `has_more` 和 `notice`。`--chat-ids` / `--has-chatted` 会作用到每一个词上。个别词失败不影响其他词(全部失败才报错)。 + 返回 `has_more=true` 表示还有更多命中,但和 `+search-user` 一样**没有分页**:收窄搜索条件(补 `--chat-ids` 或 `--has-chatted`,或换更具体的 `--query`),而不是翻页。 `--format pretty` 使用六列摘要;`table`、`csv` 和 `ndjson` 与 `+search-user` 一样使用完整结果字段。 From 1c40c594d5f790d0d4e32384c411f67e2f71d8f8 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 12:48:15 +0800 Subject: [PATCH 11/27] fix(contact): close the remaining search-user gaps in bot search A consistency pass against +search-user turned up four real divergences. The keyword error named only --query. Adding --queries made that a lie: an agent reading param="--query" concludes --queries is not a way out. Switch to WithParams naming both flags with a reason each, and fix the flag help, the tip and the skill line that still said --query was unconditionally required. p2p_chat_id carried omitempty while searchUser.P2PChatID does not, so the two sibling commands disagreed on whether the key exists when there is no p2p chat. Callers should not need a bot-specific presence check; drop omitempty and flip the test to require the empty key. affordance/contact.md had no +search-bot section, so its --help was missing the When to use / Avoid when / Examples / Related skills blocks the other three contact commands all have. Added, without a ### Tips block: affordance tips replace the shortcut's own Tips rather than adding to them, which would have hidden the fanout example and the enable_join_group warning. --chat-ids validated the raw entry count against the 100 cap and then sent duplicates through, while --user-ids is normalized and deduped by common.resolveOpenIDs before its cap is checked. Duplicates spent the server's array budget, and 101 copies of one chat were rejected here but accepted there. Normalize, dedupe on the normalized value, then check the cap -- so a chat URL and the bare id it contains collapse into one entry too. Also reuse displayInfoHighlightRE instead of a second identical regex, and add the fanout tests the sibling had and this one lacked: concurrency cap, panic contained to one query with no stack trace leaking to stderr, and all-queries- failed surfacing a typed error that keeps the upstream status. --- affordance/contact.md | 27 +++++ shortcuts/contact/contact_search_bot.go | 64 +++++++---- .../contact/contact_search_bot_fanout_test.go | 102 ++++++++++++++++++ shortcuts/contact/contact_search_bot_test.go | 70 +++++++++--- 4 files changed, 227 insertions(+), 36 deletions(-) diff --git a/affordance/contact.md b/affordance/contact.md index 13c1196837..b6d89d8353 100644 --- a/affordance/contact.md +++ b/affordance/contact.md @@ -23,6 +23,33 @@ lark-cli contact +search-user --query "alice" --as user lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user ``` +## +search-bot +Find bots (apps) the calling user can see, by keyword. Each match returns the bot's open_id plus p2p_chat_id / has_chatted so you can tell whether a conversation with it already exists. A keyword is mandatory — the filters narrow a search, they cannot list bots on their own. + +### Skills +- lark-contact/SKILL.md + +### Avoid when +- Looking for a person rather than a bot → use [[+search-user]] +- Running as a bot — this shortcut is user-only + +### Examples + +**Find a bot by name** +```bash +lark-cli contact +search-bot --query "会议助手" --as user +``` + +**Narrow to bots in one chat** +```bash +lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user +``` + +**Resolve several bot names in one call** +```bash +lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user +``` + ## +get-user Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only. diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index e06cd18e95..82b13194f5 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "net/http" - "regexp" "strconv" "strings" "unicode/utf8" @@ -28,8 +27,6 @@ const ( maxBotSearchPageSize = 30 ) -var botDisplayInfoHighlightRE = regexp.MustCompile(`(.*?)`) - type botSearchAPIRequest struct { Query string `json:"query,omitempty"` Filter *botSearchAPIFilter `json:"filter,omitempty"` @@ -63,10 +60,12 @@ type botSearchAPIMeta struct { } type searchBot struct { - OpenID string `json:"open_id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - P2PChatID string `json:"p2p_chat_id,omitempty"` + OpenID string `json:"open_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + // No omitempty: searchUser.P2PChatID always emits, and a sibling command that + // silently drops the key would force callers to special-case bot results. + P2PChatID string `json:"p2p_chat_id"` HasChatted bool `json:"has_chatted"` EnableJoinGroup bool `json:"enable_join_group"` IsAgent bool `json:"is_agent"` @@ -91,7 +90,7 @@ var ContactSearchBot = common.Shortcut{ Scopes: []string{"search:bot"}, AuthTypes: []string{"user"}, Flags: []common.Flag{ - {Name: "query", Desc: "search keyword, required (≤ 50 characters)"}, + {Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"}, {Name: "chat-ids", Desc: "narrow --query to bots in these chats (CSV of chat_id; ≤ 100)"}, {Name: "has-chatted", Type: "bool", Desc: "narrow --query to bots you've chatted with (omit to disable; =false rejected)"}, {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"}, @@ -102,7 +101,7 @@ var ContactSearchBot = common.Shortcut{ "Narrow to bots in a chat: lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user", "Narrow to bots you've chatted with: lark-cli contact +search-bot --query '助手' --has-chatted --as user", "Multi-name fanout: lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user", - "--query is required; --chat-ids and --has-chatted only narrow it — a filter-only request returns an empty list, not an error.", + "a keyword is required — pass --query or --queries; --chat-ids and --has-chatted only narrow it, and a filter-only request comes back empty rather than as an error.", "on has_more=true narrow the search (add --chat-ids or --has-chatted, or use a more specific --query) — there is no pagination.", "enable_join_group=true only means the bot is allowed into chats. Adding it needs the app's cli_ app_id, which this command does not return: the ou_ open_id here is rejected by the chat-member APIs and there is no open_id → app_id lookup. Do not claim a bot was added on the strength of this flag.", }, @@ -147,9 +146,15 @@ func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error return executeBotSearchSingle(ctx, runtime) } -func botSearchQueryRequiredError() error { - return common.ValidationErrorf("--query is required: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API returns an empty list for filter-only requests)"). - WithParam("--query") +// botSearchKeywordRequiredError names every flag that can satisfy the keyword +// requirement. Naming only --query would tell an agent that --queries is not a +// way out, which it is. +func botSearchKeywordRequiredError() error { + return common.ValidationErrorf("specify --query or --queries: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API answers a filter-only request with an empty list)"). + WithParams( + errs.InvalidParam{Name: "--query", Reason: "required unless --queries is given"}, + errs.InvalidParam{Name: "--queries", Reason: "required unless --query is given"}, + ) } func validateBotSearch(runtime *common.RuntimeContext) error { @@ -181,7 +186,7 @@ func validateBotSearch(runtime *common.RuntimeContext) error { } } else { if query == "" { - return botSearchQueryRequiredError() + return botSearchKeywordRequiredError() } if utf8.RuneCountInString(query) > maxBotSearchQueryChars { return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars). @@ -212,21 +217,34 @@ func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) { if raw == "" { return nil, nil } - chatIDs := common.SplitCSV(raw) - if len(chatIDs) == 0 { + parts := common.SplitCSV(raw) + if len(parts) == 0 { return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw). WithParam("--chat-ids") } - if len(chatIDs) > maxBotSearchChatIDs { - return nil, common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs). - WithParam("--chat-ids") - } - for i, chatID := range chatIDs { - normalized, err := common.ValidateChatIDTyped("--chat-ids", chatID) + + // Normalize before deduping, then check the cap against the deduped list — + // the same order common.resolveOpenIDs uses for --user-ids. Doing it the other + // way would spend the server's 100-entry budget on duplicates, and would let + // 101 copies of one chat be rejected here while the sibling command accepts + // them. Normalization matters too: a chat URL and a bare chat_id can name the + // same chat. + seen := make(map[string]struct{}, len(parts)) + chatIDs := make([]string, 0, len(parts)) + for _, part := range parts { + normalized, err := common.ValidateChatIDTyped("--chat-ids", part) if err != nil { return nil, err } - chatIDs[i] = normalized + if _, dup := seen[normalized]; dup { + continue + } + seen[normalized] = struct{}{} + chatIDs = append(chatIDs, normalized) + } + if len(chatIDs) > maxBotSearchChatIDs { + return nil, common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs). + WithParam("--chat-ids") } return chatIDs, nil } @@ -340,7 +358,7 @@ func projectBots(data *botSearchAPIData) []searchBot { func parseBotDisplayInfo(raw, openID string) (name, description string, matchSegments []string) { matchSegments = make([]string, 0) - for _, match := range botDisplayInfoHighlightRE.FindAllStringSubmatch(raw, -1) { + for _, match := range displayInfoHighlightRE.FindAllStringSubmatch(raw, -1) { matchSegments = append(matchSegments, match[1]) } diff --git a/shortcuts/contact/contact_search_bot_fanout_test.go b/shortcuts/contact/contact_search_bot_fanout_test.go index 61e86929ae..a8816ddfe2 100644 --- a/shortcuts/contact/contact_search_bot_fanout_test.go +++ b/shortcuts/contact/contact_search_bot_fanout_test.go @@ -6,11 +6,15 @@ package contact import ( "encoding/json" "fmt" + "net/http" "strings" + "sync/atomic" "testing" + "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" ) @@ -279,3 +283,101 @@ func TestBotFanoutMatchedQueryFidelityAndDedup(t *testing.T) { } } } + +func TestBotFanoutConcurrencyCap(t *testing.T) { + factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + + var inFlight, peak int32 + stub := botSearchStub(botSearchURL+"?page_size=20", "") + stub.Reusable = true + stub.OnMatch = func(req *http.Request) { + cur := atomic.AddInt32(&inFlight, 1) + defer atomic.AddInt32(&inFlight, -1) + for { + p := atomic.LoadInt32(&peak) + if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) { + break + } + } + time.Sleep(50 * time.Millisecond) + } + registry.Register(stub) + + queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"} + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + if peak > fanoutConcurrency { + t.Errorf("concurrency peak = %d, want <= %d", peak, fanoutConcurrency) + } + if peak < 2 { + t.Errorf("concurrency peak = %d, want >= 2 so the test actually observes parallelism", peak) + } +} + +func TestBotFanoutPanicIsContainedPerQuery(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + + boom := botSearchStub(botSearchURL, "") + boom.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"boom"`) } + boom.OnMatch = func(req *http.Request) { panic("synthetic test panic") } + registry.Register(boom) + + ok := botSearchStub(botSearchURL, "") + ok.Reusable = true + registry.Register(ok) + + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--queries", "ok,boom,fine", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("one panicking query must not bubble out of the batch; got %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("response JSON: %v\n%s", err, stdout.String()) + } + queries := got["data"].(map[string]interface{})["queries"].([]interface{}) + failed := queries[1].(map[string]interface{}) + if msg, _ := failed["error"].(string); !strings.HasPrefix(msg, "internal error:") { + t.Errorf("queries[1].error: want an 'internal error:' prefix, got %q", failed["error"]) + } + // A recovered panic must not dump a stack trace at the user. + for _, marker := range []string{"goroutine ", ".go:", "runtime."} { + if strings.Contains(stderr.String(), marker) { + t.Errorf("stderr leaked stack-trace marker %q: %s", marker, stderr.String()) + } + } +} + +func TestBotFanoutAllQueriesFailingExitsNonZero(t *testing.T) { + factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + registry.Register(&httpmock.Stub{ + Method: "POST", + URL: botSearchURL, + Reusable: true, + Status: 500, + Body: map[string]interface{}{"reason": "boom"}, + }) + + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("every query failing must surface as a command error") + } + if _, ok := errs.ProblemOf(err); !ok { + t.Fatalf("expected a typed problem, got %T: %v", err, err) + } + // The first failure's upstream status and the all-failed mode must both survive, + // so a caller can classify instead of seeing a generic internal error. + for _, want := range []string{"500", "all 2 queries failed"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("message must contain %q; got %v", want, err) + } + } +} diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 5ad49502de..beadfee966 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -63,22 +63,51 @@ func assertBotSearchValidationProblem(t *testing.T, err error, wantParam string) } } +// assertBotSearchValidationParams covers the errors that name several flags via +// WithParams; those leave the single Param empty on purpose, so an agent reading +// the envelope sees every flag that could satisfy the requirement. +func assertBotSearchValidationParams(t *testing.T, err error, wantParams []string) { + t.Helper() + if err == nil { + t.Fatal("expected validation error") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem: %+v ok=%v", problem, ok) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + got := make([]string, 0, len(validationErr.Params)) + for _, p := range validationErr.Params { + if p.Reason == "" { + t.Errorf("param %q has no reason; agents read it to pick a recovery", p.Name) + } + got = append(got, p.Name) + } + if fmt.Sprint(got) != fmt.Sprint(wantParams) { + t.Fatalf("params: got %v, want %v", got, wantParams) + } +} + func TestValidateBotSearchErrors(t *testing.T) { chatIDs := make([]string, 101) for i := range chatIDs { - chatIDs[i] = fmt.Sprintf("chat_%03d", i) + chatIDs[i] = fmt.Sprintf("oc_%03d", i) } tests := []struct { name string flags map[string]string wantParam string + wantParams []string // set instead of wantParam when the error names several flags wantMessage string }{ { - name: "query missing", - wantParam: "--query", - wantMessage: "--query is required: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API returns an empty list for filter-only requests)", + name: "keyword missing", + wantParams: []string{"--query", "--queries"}, + wantMessage: "specify --query or --queries: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API answers a filter-only request with an empty list)", }, { name: "query over 50 characters", @@ -123,16 +152,16 @@ func TestValidateBotSearchErrors(t *testing.T) { wantMessage: "--page-size: must be between 1 and 30", }, { - name: "chat ids without query", + name: "chat ids without a keyword", flags: map[string]string{"chat-ids": "oc_a"}, - wantParam: "--query", - wantMessage: "--query is required: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API returns an empty list for filter-only requests)", + wantParams: []string{"--query", "--queries"}, + wantMessage: "specify --query or --queries: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API answers a filter-only request with an empty list)", }, { - name: "has chatted without query", + name: "has chatted without a keyword", flags: map[string]string{"has-chatted": "true"}, - wantParam: "--query", - wantMessage: "--query is required: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API returns an empty list for filter-only requests)", + wantParams: []string{"--query", "--queries"}, + wantMessage: "specify --query or --queries: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API answers a filter-only request with an empty list)", }, } @@ -144,7 +173,11 @@ func TestValidateBotSearchErrors(t *testing.T) { } runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig()) err := validateBotSearch(runtime) - assertBotSearchValidationProblem(t, err, tt.wantParam) + if len(tt.wantParams) > 0 { + assertBotSearchValidationParams(t, err, tt.wantParams) + } else { + assertBotSearchValidationProblem(t, err, tt.wantParam) + } if err.Error() != tt.wantMessage { t.Fatalf("message: got %q, want %q", err.Error(), tt.wantMessage) } @@ -167,6 +200,11 @@ func TestValidateBotSearchPassingCases(t *testing.T) { // parses to zero entries is an error. {name: "blank chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": ""}}, {name: "whitespace chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": " "}}, + // Duplicates collapse before the cap is checked, so 101 copies of one chat + // is one entry — matching how --user-ids is resolved for +search-user. + {name: "duplicate chat ids collapse under the cap", flags: map[string]string{ + "query": "x", "chat-ids": strings.TrimSuffix(strings.Repeat("oc_a,", 101), ","), + }}, } for _, tt := range tests { @@ -221,6 +259,10 @@ func TestBuildBotSearchBody(t *testing.T) { {name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`}, // A blank --chat-ids must not materialize an empty filter object. {name: "blank chat ids omit filter", flags: map[string]string{"query": "x", "chat-ids": " "}, wantJSON: `{"query":"x"}`}, + // Deduped after normalization, so a repeated id and a URL naming the same + // chat both collapse into one entry instead of burning the server's quota. + {name: "duplicate chat ids deduped", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`}, + {name: "URL and bare id dedupe to one", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_a"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a"]}}`}, } for _, tt := range tests { @@ -317,8 +359,10 @@ func TestProjectBotsMapsEveryField(t *testing.T) { if err != nil { t.Fatalf("marshal response: %v", err) } - if strings.Contains(string(raw), `"p2p_chat_id":""`) { - t.Fatalf("empty p2p_chat_id must be omitted: %s", raw) + // searchUser emits p2p_chat_id unconditionally; the sibling command must keep + // the same key set so callers need no bot-specific presence check. + if !strings.Contains(string(raw), `"p2p_chat_id":""`) { + t.Fatalf("empty p2p_chat_id must still be emitted: %s", raw) } } From 719b24f922f9deafc73ea5a112045260462bc881 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 14:14:26 +0800 Subject: [PATCH 12/27] fix(contact): stop bot search help pointing at --query only Three leftovers from the review, all in guidance rather than request shaping. --chat-ids and --has-chatted described themselves as narrowing "--query", written before --queries existed. Both filters apply to every keyword in a fanout, so the text told agents a combination they can use is unavailable, and the skill doc contradicted itself: one paragraph demanded --query on every call, another explained the filters applying across --queries. Both now say a keyword search, and the skill line names either flag. Kept "narrow" over the sibling's "restrict": these two filters genuinely cannot enumerate on their own. The affordance section repeated the domain-level skill, so Related skills listed lark-contact and lark-contact/SKILL.md -- the same file twice, since the merge dedupes on the raw string. +search-user's second entry is a distinct per-command reference; there is no such file here, so drop the block and let the domain entry stand alone. Validation reported the missing keyword before rejecting an explicit --has-chatted=false, so a caller passing only that flag had to fix two errors in sequence, the first of which was not what was actually wrong. +search-user lands on the =false error first because it counts a Changed bool as search input; move the check ahead of the keyword requirement to reach the same place, and pin the order with a test. Also add the fanout tests the sibling still had and this one did not: partial failure keeping the surviving query's rows plus the stderr counters, ndjson staying parseable with no summary line mixed in, and a cancelled context short-circuiting every query before it reaches the transport. --- affordance/contact.md | 3 - shortcuts/contact/contact_search_bot.go | 24 +++--- .../contact/contact_search_bot_fanout_test.go | 78 +++++++++++++++++++ shortcuts/contact/contact_search_bot_test.go | 9 +++ skills/lark-contact/SKILL.md | 2 +- 5 files changed, 103 insertions(+), 13 deletions(-) diff --git a/affordance/contact.md b/affordance/contact.md index b6d89d8353..4f2592110c 100644 --- a/affordance/contact.md +++ b/affordance/contact.md @@ -26,9 +26,6 @@ lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user ## +search-bot Find bots (apps) the calling user can see, by keyword. Each match returns the bot's open_id plus p2p_chat_id / has_chatted so you can tell whether a conversation with it already exists. A keyword is mandatory — the filters narrow a search, they cannot list bots on their own. -### Skills -- lark-contact/SKILL.md - ### Avoid when - Looking for a person rather than a bot → use [[+search-user]] - Running as a bot — this shortcut is user-only diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 82b13194f5..402a478c39 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -91,8 +91,8 @@ var ContactSearchBot = common.Shortcut{ AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"}, - {Name: "chat-ids", Desc: "narrow --query to bots in these chats (CSV of chat_id; ≤ 100)"}, - {Name: "has-chatted", Type: "bool", Desc: "narrow --query to bots you've chatted with (omit to disable; =false rejected)"}, + {Name: "chat-ids", Desc: "narrow a keyword search to bots in these chats (CSV of chat_id; ≤ 100)"}, + {Name: "has-chatted", Type: "bool", Desc: "narrow a keyword search to bots you've chatted with (omit to disable; =false rejected)"}, {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"}, {Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"}, }, @@ -158,6 +158,19 @@ func botSearchKeywordRequiredError() error { } func validateBotSearch(runtime *common.RuntimeContext) error { + // Checked before the keyword requirement: an explicit =false is wrong on its + // own terms, so reporting the missing keyword first would send the caller off + // to add a query and only then reveal the flag it actually has to drop. + // +search-user reaches the same error first because it counts a Changed bool + // as search input. + // + // Agents passing =false almost always mean "do not filter", but the API reads + // it as "must NOT match". A hard error prevents silent wrong results. + if runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted") { + return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)"). + WithParam("--has-chatted") + } + queriesRaw := strings.TrimSpace(runtime.Str("queries")) query := strings.TrimSpace(runtime.Str("query")) @@ -198,13 +211,6 @@ func validateBotSearch(runtime *common.RuntimeContext) error { return err } - // Agents passing =false almost always mean "do not filter", but the API - // reads it as "must NOT match". A hard error prevents silent wrong results. - if runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted") { - return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)"). - WithParam("--has-chatted") - } - if n := runtime.Int("page-size"); n < 1 || n > maxBotSearchPageSize { return common.ValidationErrorf("--page-size: must be between 1 and %d", maxBotSearchPageSize). WithParam("--page-size") diff --git a/shortcuts/contact/contact_search_bot_fanout_test.go b/shortcuts/contact/contact_search_bot_fanout_test.go index a8816ddfe2..f30c091ccc 100644 --- a/shortcuts/contact/contact_search_bot_fanout_test.go +++ b/shortcuts/contact/contact_search_bot_fanout_test.go @@ -4,6 +4,7 @@ package contact import ( + "context" "encoding/json" "fmt" "net/http" @@ -381,3 +382,80 @@ func TestBotFanoutAllQueriesFailingExitsNonZero(t *testing.T) { } } } + +func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + + broken := botSearchStub(botSearchURL, "") + broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) } + broken.Status = 500 + broken.Body = map[string]interface{}{"reason": "boom"} + registry.Register(broken) + + okStub := botSearchStub(botSearchURL, "") + okStub.Reusable = true + registry.Register(okStub) + + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--queries", "会议,日报", "--format", "csv", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("one failing query must not fail the batch: %v", err) + } + + // The surviving query's notice must still reach the caller. + if !strings.Contains(stdout.String(), "会议") { + t.Errorf("surviving query's rows missing from stdout: %s", stdout.String()) + } + // csv is in the summary format set, so the per-batch counters go to stderr. + if !strings.Contains(stderr.String(), "2 queries") || !strings.Contains(stderr.String(), "1 failed") { + t.Errorf("stderr summary must report the batch counters: %s", stderr.String()) + } +} + +func TestBotFanoutNDJSONKeepsStdoutClean(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + stub := botSearchStub(botSearchURL, "") + stub.Reusable = true + registry.Register(stub) + + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--queries", "会议,日报", "--format", "ndjson", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + // ndjson is a machine format outside the summary set: every stdout line must + // parse, and the counters must not be mixed in. + for i, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") { + if line == "" { + continue + } + var row map[string]interface{} + if err := json.Unmarshal([]byte(line), &row); err != nil { + t.Fatalf("stdout line %d is not JSON: %q", i, line) + } + } + if strings.Contains(stderr.String(), "queries,") { + t.Errorf("ndjson must not emit the summary line: %s", stderr.String()) + } +} + +func TestBotFanoutCancelledContextFailsEveryQuery(t *testing.T) { + results := make([]botFanoutResult, 0, 2) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + for i, q := range []string{"会议", "日报"} { + results = append(results, runOneBotQuery(ctx, nil, i, q, nil)) + } + for _, r := range results { + if r.ErrMsg == "" { + t.Fatalf("a cancelled context must short-circuit before the request: %+v", r) + } + } + // The pre-check exists so queued workers never issue a request after cancel; + // reaching DoAPI with a nil runtime would panic instead. + if _, err := buildBotFanoutResponse([]string{"会议", "日报"}, results); err == nil { + t.Fatal("all queries cancelled must surface as an error") + } +} diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index beadfee966..868f35c943 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -133,6 +133,15 @@ func TestValidateBotSearchErrors(t *testing.T) { wantParam: "--chat-ids", wantMessage: "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)", }, + { + // Order matters: the explicit =false is the caller's actual mistake, so it + // must win over the missing-keyword error rather than costing a second + // round trip. Matches which error +search-user reports first. + name: "has chatted false without a keyword", + flags: map[string]string{"has-chatted": "false"}, + wantParam: "--has-chatted", + wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)", + }, { name: "has chatted false", flags: map[string]string{"query": "x", "has-chatted": "false"}, diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index ccd61143d2..c13c82721f 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -45,7 +45,7 @@ lark-cli im +messages-send --user-id ou_xxx --text "Hi!" lark-cli contact +search-bot --query '会议助手' --as user ``` -`--chat-ids` 和 `--has-chatted` 只能缩小关键词搜索范围,每次调用仍须传入 `--query`: +`--chat-ids` 和 `--has-chatted` 只能缩小关键词搜索范围,每次调用都要给关键词(`--query` 或 `--queries`): ```bash lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user From 1e0370cc8262dfa8260419bfcd0b830828d92c52 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 14:23:27 +0800 Subject: [PATCH 13/27] fix(contact): scope the has-chatted=false check to the no-keyword case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last round hoisted the explicit =false rejection to the top of validation to fix which error a bare --has-chatted=false reports. That overshot: sitting ahead of every keyword check, it also masked them. Measured against the sibling, --query x --queries y --has-chatted=false returned --has-chatted here and the mutual-exclusion error there, and a 51-character --query alongside =false returned --has-chatted here and the length error there. Fixing one combination had broken two others. +search-user only reaches the =false error first when nothing else was supplied: a Changed bool satisfies its "at least one search input" gate, then the keyword checks no-op, then =false fires. Reproduce that shape instead of reordering wholesale -- the =false error now returns from the no-keyword branch, and otherwise stays after the keyword and chat-id checks. All four combinations now report the same param, params and message as +search-user. The partial-failure fanout test claimed to prove the surviving query's notice reaches the caller but only checked that CSV stdout contained a keyword, which comes from the row rather than the notice; deleting either notice assignment left it green. Rewritten against JSON with explicit assertions on the top-level notice, the surviving query's sidecar notice, the failed query's upstream status and the single contributed row — verified by deleting the assignment and watching it fail. The CSV summary it used to cover moved into its own test that also pins the matched_query column. Also add the fanout dry-run test: one previewed request per deduped keyword, each carrying the filter, and no page_token in the preview. --- shortcuts/contact/contact_search_bot.go | 51 +++++--- .../contact/contact_search_bot_fanout_test.go | 114 ++++++++++++++++-- shortcuts/contact/contact_search_bot_test.go | 18 ++- 3 files changed, 154 insertions(+), 29 deletions(-) diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 402a478c39..32131862aa 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -157,22 +157,20 @@ func botSearchKeywordRequiredError() error { ) } -func validateBotSearch(runtime *common.RuntimeContext) error { - // Checked before the keyword requirement: an explicit =false is wrong on its - // own terms, so reporting the missing keyword first would send the caller off - // to add a query and only then reveal the flag it actually has to drop. - // +search-user reaches the same error first because it counts a Changed bool - // as search input. - // - // Agents passing =false almost always mean "do not filter", but the API reads - // it as "must NOT match". A hard error prevents silent wrong results. - if runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted") { - return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)"). - WithParam("--has-chatted") - } +// botSearchHasChattedFalseError is raised from two places — with and without a +// keyword — so the wording stays in one spot. +// +// Agents passing =false almost always mean "do not filter", but the API reads it +// as "must NOT match". A hard error prevents silent wrong results. +func botSearchHasChattedFalseError() error { + return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)"). + WithParam("--has-chatted") +} +func validateBotSearch(runtime *common.RuntimeContext) error { queriesRaw := strings.TrimSpace(runtime.Str("queries")) query := strings.TrimSpace(runtime.Str("query")) + explicitFalseHasChatted := runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted") if queriesRaw != "" { if query != "" { @@ -197,20 +195,33 @@ func validateBotSearch(runtime *common.RuntimeContext) error { WithParam("--queries") } } - } else { - if query == "" { - return botSearchKeywordRequiredError() - } - if utf8.RuneCountInString(query) > maxBotSearchQueryChars { - return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars). - WithParam("--query") + } else if query == "" { + // No keyword at all. An explicit =false is the more specific mistake, so + // report it instead of sending the caller off to add a keyword only to hit + // this on the next attempt. +search-user lands here too: a Changed bool + // counts as search input for its "at least one" gate, so the =false check + // is what it reaches next. + // + // Scoped to the no-keyword case on purpose. Hoisting it above the keyword + // checks would let it mask the mutual-exclusion and length errors, which + // +search-user reports first when a keyword is present. + if explicitFalseHasChatted { + return botSearchHasChattedFalseError() } + return botSearchKeywordRequiredError() + } else if utf8.RuneCountInString(query) > maxBotSearchQueryChars { + return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars). + WithParam("--query") } if _, err := parseBotSearchChatIDs(runtime); err != nil { return err } + if explicitFalseHasChatted { + return botSearchHasChattedFalseError() + } + if n := runtime.Int("page-size"); n < 1 || n > maxBotSearchPageSize { return common.ValidationErrorf("--page-size: must be between 1 and %d", maxBotSearchPageSize). WithParam("--page-size") diff --git a/shortcuts/contact/contact_search_bot_fanout_test.go b/shortcuts/contact/contact_search_bot_fanout_test.go index f30c091ccc..86ec1bd357 100644 --- a/shortcuts/contact/contact_search_bot_fanout_test.go +++ b/shortcuts/contact/contact_search_bot_fanout_test.go @@ -384,7 +384,7 @@ func TestBotFanoutAllQueriesFailingExitsNonZero(t *testing.T) { } func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) { - factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) broken := botSearchStub(botSearchURL, "") broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) } @@ -397,18 +397,61 @@ func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) { registry.Register(okStub) err := mountAndRun(t, ContactSearchBot, []string{ - "+search-bot", "--queries", "会议,日报", "--format", "csv", "--as", "user", + "+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("one failing query must not fail the batch: %v", err) } - // The surviving query's notice must still reach the caller. - if !strings.Contains(stdout.String(), "会议") { - t.Errorf("surviving query's rows missing from stdout: %s", stdout.String()) + var envelope struct { + Data botFanoutResponse `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("response JSON: %v\n%s", err, stdout.String()) + } + + const wantNotice = "The query is too long and has been truncated to the first 50 characters for search." + // Assert the notice itself, not just that some row survived: the surviving + // query's server remark has to reach the caller both at the top level and in + // its own sidecar entry. + if envelope.Data.Notice != wantNotice { + t.Errorf("top-level notice: got %q, want %q", envelope.Data.Notice, wantNotice) + } + if len(envelope.Data.Queries) != 2 { + t.Fatalf("both queries must be enumerated: %+v", envelope.Data.Queries) + } + if envelope.Data.Queries[0].Notice != wantNotice { + t.Errorf("surviving query notice: got %q, want %q", envelope.Data.Queries[0].Notice, wantNotice) + } + if envelope.Data.Queries[0].Error != "" { + t.Errorf("surviving query must carry no error: %q", envelope.Data.Queries[0].Error) + } + if !strings.Contains(envelope.Data.Queries[1].Error, "500") { + t.Errorf("failed query must carry the upstream status: %q", envelope.Data.Queries[1].Error) + } + // Only the surviving query contributes rows. + if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].MatchedQuery != "会议" { + t.Fatalf("bots: %+v", envelope.Data.Bots) + } +} + +func TestBotFanoutCSVCarriesMatchedQueryAndSummary(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + stub := botSearchStub(botSearchURL, "") + stub.Reusable = true + registry.Register(stub) + + err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--queries", "会议,日报", "--format", "csv", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(stdout.String(), "matched_query") { + t.Errorf("csv must expose matched_query so rows can be traced to a keyword: %s", stdout.String()) } - // csv is in the summary format set, so the per-batch counters go to stderr. - if !strings.Contains(stderr.String(), "2 queries") || !strings.Contains(stderr.String(), "1 failed") { + // csv is in the summary format set, so the batch counters belong on stderr. + if !strings.Contains(stderr.String(), "2 queries") || !strings.Contains(stderr.String(), "0 failed") { t.Errorf("stderr summary must report the batch counters: %s", stderr.String()) } } @@ -459,3 +502,60 @@ func TestBotFanoutCancelledContextFailsEveryQuery(t *testing.T) { t.Fatal("all queries cancelled must surface as an error") } } + +func TestBotFanoutDryRunPreviewsOneRequestPerKeyword(t *testing.T) { + cmd := newBotSearchTestCommand() + setBotSearchFlag(t, cmd, "queries", "会议, 日报 ,会议") + setBotSearchFlag(t, cmd, "chat-ids", "oc_a") + setBotSearchFlag(t, cmd, "has-chatted", "true") + runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig()) + + raw, err := json.Marshal(ContactSearchBot.DryRun(context.Background(), runtime)) + if err != nil { + t.Fatalf("marshal dry-run: %v", err) + } + var preview struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body struct { + Query string `json:"query"` + Filter *struct { + ChatIDs []string `json:"chat_ids"` + HasChatter bool `json:"has_chatter"` + } `json:"filter"` + } `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(raw, &preview); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, raw) + } + + // Deduped, so the repeated keyword previews once — the preview has to match + // the requests Execute would actually issue. + if len(preview.API) != 2 { + t.Fatalf("expected one previewed request per deduped keyword, got %d: %s", len(preview.API), raw) + } + seen := make([]string, 0, len(preview.API)) + for i, call := range preview.API { + if call.Method != "POST" || call.URL != botSearchURL { + t.Errorf("api[%d]: got %s %s", i, call.Method, call.URL) + } + if call.Params["page_size"] != float64(20) { + t.Errorf("api[%d] page_size: %v", i, call.Params["page_size"]) + } + if _, ok := call.Params["page_token"]; ok { + t.Errorf("api[%d] must not preview a page_token: %v", i, call.Params) + } + // The filter rides along with every keyword, not just the first. + if call.Body.Filter == nil || !call.Body.Filter.HasChatter || + len(call.Body.Filter.ChatIDs) != 1 || call.Body.Filter.ChatIDs[0] != "oc_a" { + t.Errorf("api[%d] filter: %+v", i, call.Body.Filter) + } + seen = append(seen, call.Body.Query) + } + if fmt.Sprint(seen) != fmt.Sprint([]string{"会议", "日报"}) { + t.Errorf("previewed keywords: got %v, want [会议 日报]", seen) + } +} diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 868f35c943..af4a7935b5 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -134,8 +134,22 @@ func TestValidateBotSearchErrors(t *testing.T) { wantMessage: "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)", }, { - // Order matters: the explicit =false is the caller's actual mistake, so it - // must win over the missing-keyword error rather than costing a second + // With a keyword present the keyword errors win, exactly as +search-user + // orders them; the =false check must not be hoisted above these. + name: "mutually exclusive keywords outrank has chatted false", + flags: map[string]string{"query": "x", "queries": "y", "has-chatted": "false"}, + wantParams: []string{"--query", "--queries"}, + wantMessage: "--query and --queries are mutually exclusive", + }, + { + name: "query length outranks has chatted false", + flags: map[string]string{"query": strings.Repeat("中", 51), "has-chatted": "false"}, + wantParam: "--query", + wantMessage: "--query: length must be between 1 and 50 characters", + }, + { + // With no keyword at all the explicit =false is the more specific mistake, + // so it wins over the missing-keyword error rather than costing a second // round trip. Matches which error +search-user reports first. name: "has chatted false without a keyword", flags: map[string]string{"has-chatted": "false"}, From b353b8000b5bc2914d61e06cc0004ce0ff002831 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 14:53:03 +0800 Subject: [PATCH 14/27] docs(contact): restore unrelated skill content and split bot search reference Two things were wrong with how this branch edited skills/lark-contact/SKILL.md. It changed content that has nothing to do with bot search. Commit 2830d5e2 rewrote the user_profiles batch_query row in the command table, rewrote the paragraph introducing it, and deleted its whole example block, redirecting readers to lark-openapi-explorer. That was done to silence a local quality-gate rejection -- "example references unknown command contact user_profiles batch_query" -- which only fires because a stale internal/registry/meta_data.json carries 13 services and no contact service. Refreshing the metadata makes the gate pass with the documentation intact, so the removal deleted correct content to work around a local artifact. Restored verbatim. It also grew the bot search section inside the main skill file while the sibling keeps that depth in references/lark-contact-search-user.md, so the section had swollen past the surrounding material and still lacked the input and output contract details the sibling documents. Added references/lark-contact-search-bot.md covering the normalization rules (--queries dedupe, --chat-ids URL normalization, both caps counted after dedupe), the field contract, the fanout shape including the absent top-level has_more and the per-query error, and which --format values put the fanout counters on stderr. The main file keeps a short section that links to it, the way the +search-user row already does. Everything else in the file is now purely additive: the new table row, the new section and two new caveat bullets, with the pre-existing 41050 and ID-type bullets left untouched. The description front matter is the one edit that cannot be an addition -- a skill has a single description and the capability has to appear there for routing -- so it only inserts clauses and leaves every original word in place. Also corrects the pretty column count: the fanout table adds matched_query, so it is seven columns, not the six the old line claimed unconditionally. --- skills/lark-contact/SKILL.md | 44 +++----- .../references/lark-contact-search-bot.md | 102 ++++++++++++++++++ 2 files changed, 119 insertions(+), 27 deletions(-) create mode 100644 skills/lark-contact/references/lark-contact-search-bot.md diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index c13c82721f..63028ffaae 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-contact version: 1.0.0 -description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人。当用户提到某人姓名要下一步发消息 / 排日程,拿到 open_id 想查具体信息,或需要查找机器人 open_id 时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" +description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息,或需要查找机器人 open_id 时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" metadata: requires: bins: ["lark-cli"] @@ -15,10 +15,10 @@ metadata: | 想做什么 | user 身份 | bot 身份 | |---|---|---| | 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 | -| 按名称搜索当前用户可见的机器人 | `+search-bot --query <关键词>` | 不支持 | +| 按名称搜索当前用户可见的机器人 | [`+search-bot`](references/lark-contact-search-bot.md) | 不支持 | | 已知 open_id 取他人资料 | `+search-user --user-ids ` | [`+get-user --user-id `](references/lark-contact-get-user.md) | | 查看自己 | `+get-user` 或 `+search-user --user-ids me` | 不支持 | -| 查同事的个人状态 / 签名 | [`lark-openapi-explorer`](../lark-openapi-explorer/SKILL.md) | 不支持 | +| 查同事的个人状态 / 签名 | `user_profiles batch_query` | 不支持 | 已知 open_id 只是想发消息 / 排日程,不必经过 contact —— 直接 [`lark-im`](../lark-im/SKILL.md) / [`lark-calendar`](../lark-calendar/SKILL.md)。 @@ -31,46 +31,36 @@ lark-cli contact +search-user --query "张三" --has-chatted --as user lark-cli im +messages-send --user-id ou_xxx --text "Hi!" ``` -批量查同事的个人状态 / 个性签名时,当前命令清单没有对应的内置 contact 命令,交给 [`lark-openapi-explorer`](../lark-openapi-explorer/SKILL.md) 查找原生 OpenAPI。 - -搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。 - -## 搜索机器人 - -`+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人。返回的 `open_id` 是 `ou_` 开头的机器人 open_id,用于标识这个机器人(能不能用于某个下游接口取决于该接口接受的 ID 类型,见下文入群的例子)。`p2p_chat_id` 表示当前用户与机器人的单聊会话,`has_chatted` 表示是否存在该会话。 - -按关键词搜索: +批量查同事的个人状态 / 个性签名(先用 schema 看参数)。 ```bash -lark-cli contact +search-bot --query '会议助手' --as user +lark-cli schema contact.user_profiles.batch_query +lark-cli contact user_profiles batch_query \ + --params '{"user_id_type":"open_id"}' \ + --data '{"user_ids":["ou_xxx","ou_yyy"],"query_option":{"include_personal_status":true,"include_description":true}}' \ + --as user ``` -`--chat-ids` 和 `--has-chatted` 只能缩小关键词搜索范围,每次调用都要给关键词(`--query` 或 `--queries`): +搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。 -```bash -lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user -lark-cli contact +search-bot --query '助手' --has-chatted --as user -``` +## 搜索机器人 -一次要找多个机器人时用 `--queries`(和 `--query` 互斥),逗号分隔、并行搜、最多 20 个词: +`+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人,返回 `ou_` 开头的机器人 open_id。参数细节、输入归一化规则和输出结构见 [`lark-contact-search-bot.md`](references/lark-contact-search-bot.md)。 ```bash +lark-cli contact +search-bot --query '会议助手' --as user lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user ``` -输出是扁平的 `bots[]`,每行多一个 `matched_query` 说明是哪个词命中的;另有 `queries[]` 汇总逐词的 `has_more` 和 `notice`。`--chat-ids` / `--has-chatted` 会作用到每一个词上。个别词失败不影响其他词(全部失败才报错)。 - -返回 `has_more=true` 表示还有更多命中,但和 `+search-user` 一样**没有分页**:收窄搜索条件(补 `--chat-ids` 或 `--has-chatted`,或换更具体的 `--query`),而不是翻页。 - -`--format pretty` 使用六列摘要;`table`、`csv` 和 `ndjson` 与 `+search-user` 一样使用完整结果字段。 - `enable_join_group=true` 只表示该机器人允许被拉进群聊,**不代表你能用这里的 `open_id` 把它拉进群**。把机器人加入群聊需要应用的 `cli_` 开头 app_id,本命令不返回;直接用 `ou_` 开头的 open_id 调加群接口会被服务端放进 `invalid_id_list`,且没有 open_id 到 app_id 的查询接口。看到这个字段为真时,不要据此声称已把机器人加入群聊。 ## 注意事项 -- **41050 / Permission denied** 按命令处理:`+search-user` 只支持 user 身份,重新授权 `contact:user:search`;`+search-bot` 只支持 user 身份,重新授权 `search:bot`;`+get-user` 同时支持 user 和 bot,可改用具备对应通讯录权限的身份。身份与授权细节见 [`lark-shared`](../lark-shared/SKILL.md)。 +- **41050 / Permission denied** 受当前身份的可见范围限制(两条命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。 - **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。 -- **ID 类型**:默认 `open_id`。`+get-user` 原样透传服务端响应,可改 `--user-id-type union_id|user_id`;`+search-user` 和 `+search-bot` 有固定的输出结构,一律只出 `open_id`,不接受切换(两个接口本身支持 `user_id_type`,但字段名会随之说谎,所以 CLI 不暴露;确实要 union_id / user_id 时走 `lark-cli api` 直调)。 +- **ID 类型**:默认 `open_id`。`+get-user` 可改 `--user-id-type union_id|user_id`;`+search-user` 只接受 `open_id`。 +- **`+search-bot` 的权限**:只支持 user 身份,缺权限时重新授权 `search:bot`。 +- **`+search-bot` 的 ID 类型**:和 `+search-user` 一样只出 `open_id`。接口本身支持 `user_id_type`,但输出结构的字段名会随之说谎,所以 CLI 不暴露;确实要 union_id / user_id 时走 `lark-cli api` 直调。 ## 不在本 skill 范围 diff --git a/skills/lark-contact/references/lark-contact-search-bot.md b/skills/lark-contact/references/lark-contact-search-bot.md new file mode 100644 index 0000000000..ae22275da5 --- /dev/null +++ b/skills/lark-contact/references/lark-contact-search-bot.md @@ -0,0 +1,102 @@ +# +search-bot + +仅支持 user 身份,需要 `search:bot` 权限。 + +## 适用范围 + +- ✅ 已知机器人名字(或名字片段)想找出它的 open_id +- ✅ 一次解析多个机器人名字(`--queries`,最多 20 个词) +- ✅ 想知道某个群里有哪些机器人、或自己和哪些机器人聊过天 —— 但都要配关键词 +- ❌ 不给关键词、只想列出全部可见机器人 → 接口不支持,见下文 +- ❌ 已知 open_id 想给机器人发消息 → 直接走 `lark-im`,不经过本命令 + +## 关键 flag + +**必须给关键词**:`--query` 或 `--queries` 至少一个。`--chat-ids` 和 `--has-chatted` 只能收窄关键词搜索,不能独立枚举 —— 服务端对纯 filter 请求返回**空列表而不是报错**,所以 CLI 提前拦住,避免"没有这种机器人"的假结论。 + +| Flag | 作用 | +|---|---| +| `--query ` | 关键词,≤ 50 字符(按字符计,不是字节) | +| `--queries ` | 多个关键词并行搜,**最多 20 个唯一词**,每词 ≤ 50 字符;与 `--query` 互斥;输出 shape 不同(见下) | +| `--chat-ids ` | 只在这些群里找,**最多 100 个去重后的 chat_id** | +| `--has-chatted` | 只要和自己有单聊会话的;显式传 `=false` 会报错 —— 不传等于不过滤 | +| `--page-size ` | 每次返回条数,1–30(服务端上限就是 30) | + +### 输入归一化规则 + +- `--queries`:去首尾空白 → 丢弃空项 → 大小写敏感的精确去重 → 保留首次出现顺序。**20 个上限是按去重后的数量算的**,`'助手,助手,助手'` 只发一次请求。 +- `--chat-ids`:接受裸 `oc_...`,也接受包含 `oc_...` 的飞书 / Lark 群链接 —— 链接会先归一化成裸 chat_id,**再**按 chat_id 去重,**最后**才检查 100 上限。所以同一个群写成链接和裸 ID 各传一遍,只算一个。 + +## 常用例子 + +```bash +# 按名字找,拿 open_id +lark-cli contact +search-bot --query '会议助手' --as user + +# 只在某个群里找 +lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user + +# 只要聊过天的 +lark-cli contact +search-bot --query '助手' --has-chatted --as user + +# 一次解析多个名字 +lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user +``` + +## 没有分页 + +和 `+search-user` 一致:没有 `--page-token`,输出也不给 `page_token`。`has_more=true` 时**收窄搜索**(补 `--chat-ids` / `--has-chatted`,或换更具体的关键词),而不是翻页。 + +## 注意事项 + +- **`enable_join_group=true` 不等于你能把它拉进群。** 加机器人进群需要应用的 `cli_` 开头 app_id,本命令不返回;拿这里的 `ou_` open_id 去调加群接口,服务端会把它放进 `invalid_id_list`,而且没有 open_id → app_id 的查询接口。看到这个字段为真时不要声称已加入成功。 +- **`meta_data.chat_id` 的官方文档写错了。** 文档说是"机器人所属的群聊 ID",实际是**调用者与该机器人的单聊会话**。本命令按后者投影成 `p2p_chat_id`。 +- **ID 类型只出 `open_id`。** 接口本身支持 `user_id_type`(实测 `union_id` 会返回 `on_...`),但本命令有固定输出结构,字段名会随之说谎,所以不暴露该参数;确实要 union_id / user_id 时走 `lark-cli api` 直调。 +- **`notice` 原样透出。** 服务端用它说明本次搜索的额外情况(如结果不全),不要忽略。 + +## 输出字段 contract + +单关键词模式: + +``` +bots[] 每个机器人一条 + open_id ou_ 开头,机器人的 open_id + name 从 display_info 第一行解析 + description 从 display_info 第二行解析(可能为空,此时省略该字段) + p2p_chat_id 与调用者的单聊会话;没有则为空字符串(字段仍存在) + has_chatted p2p_chat_id 是否非空 + enable_join_group 是否允许被拉进群聊 + is_agent 是否是智能体 + tenant_id 租户标识(可能省略) + match_segments[] 命中的关键词片段;无命中时为 [] +has_more 还有更多命中 +notice 服务端补充说明(可能省略) +``` + +### `--queries` 模式额外字段 + +顶层 shape 变成 `{bots[], queries[], notice?}` —— **没有顶层 `has_more`**,`has_more` 只在 `queries[]` 逐词给出。 + +``` +bots[] 比单关键词模式多一个字段: + matched_query 这一行是被哪个关键词命中的 +queries[] 按去重后的输入顺序,每个词一条: + query 关键词原文 + error 该词失败的原因(成功时省略) + has_more 该词是否还有更多命中 + notice 该词的服务端补充说明(可能省略) +``` + +个别词失败不影响其他词,命令仍然成功退出,失败原因在对应的 `queries[].error` 里;**只有全部词都失败才报错**,且首个失败的分类(HTTP 状态 / API code)会透传出来。 + +### 各 `--format` 的差异 + +| format | stdout | 扇出计数写 stderr | +|---|---|---| +| `pretty` | 摘要表:单关键词 6 列;`--queries` 模式多一列 `matched_query`,共 7 列 | 是 | +| `table` | 完整结果字段 | 是 | +| `csv` | 完整结果字段 | 是 | +| `json` | 完整信封 | 否 | +| `ndjson` | 每行一条完整记录 | 否 | + +扇出计数那行形如 `2 queries, 3 total bots; 1 failed, 0 with has_more`,只在 `pretty` / `table` / `csv` 下输出。用 stdout 做管道时选 `json` 或 `ndjson`,不会混入这行。 From 58ff434a6bbb537444f9c15ab45d2dcbc6cc5083 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 15:08:43 +0800 Subject: [PATCH 15/27] docs(skill): clarify group join requirements and ID type restrictions for bot search --- skills/lark-contact/SKILL.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index 63028ffaae..b6c516e773 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -52,15 +52,14 @@ lark-cli contact +search-bot --query '会议助手' --as user lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user ``` -`enable_join_group=true` 只表示该机器人允许被拉进群聊,**不代表你能用这里的 `open_id` 把它拉进群**。把机器人加入群聊需要应用的 `cli_` 开头 app_id,本命令不返回;直接用 `ou_` 开头的 open_id 调加群接口会被服务端放进 `invalid_id_list`,且没有 open_id 到 app_id 的查询接口。看到这个字段为真时,不要据此声称已把机器人加入群聊。 +`enable_join_group=true` 只表示该机器人允许被拉进群聊,**不代表你能用这里的 `open_id` 把它拉进群**。把机器人加入群聊需要应用的 `cli_` 开头 app_id,本命令不返回; ## 注意事项 - **41050 / Permission denied** 受当前身份的可见范围限制(两条命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。 - **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。 -- **ID 类型**:默认 `open_id`。`+get-user` 可改 `--user-id-type union_id|user_id`;`+search-user` 只接受 `open_id`。 +- **ID 类型**:默认 `open_id`。`+get-user` 可改 `--user-id-type union_id|user_id`;`+search-user`和 `+search-bot` 只接受 `open_id`。 - **`+search-bot` 的权限**:只支持 user 身份,缺权限时重新授权 `search:bot`。 -- **`+search-bot` 的 ID 类型**:和 `+search-user` 一样只出 `open_id`。接口本身支持 `user_id_type`,但输出结构的字段名会随之说谎,所以 CLI 不暴露;确实要 union_id / user_id 时走 `lark-cli api` 直调。 ## 不在本 skill 范围 From fcb661bebe99fc9fed3c392741f04c7d6cbb9b91 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 15:33:17 +0800 Subject: [PATCH 16/27] fix(contact): --chat-ids changes the search scope, it does not narrow it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight places -- the command Description, the --chat-ids flag help, two tips, the keyword-required error, the stderr hint, the affordance blurb and the reference doc -- called --chat-ids a narrowing filter. Live probing shows it is not: a bot inside a chat can be absent from the tenant-wide result set entirely, so naming the chat widens what is reachable rather than filtering it down. query '红黑榜' no filter -> 0 rows with 3 eng chats -> 红黑榜小助手 query '助' no filter -> 2 rows with 3 eng chats -> 红黑榜小助手 The second row is the point: the filtered result is not a subset, it is a different set. --has-chatted by contrast is a real narrowing filter -- the same '助' query drops from two rows to one, keeping the same open_id -- so wording about narrowing now refers only to it. This mattered beyond accuracy. An agent told --chat-ids "only narrows" would never reach for it to *find* a bot a plain --query cannot see, which is the only way to answer "what bot is in this chat". A tip now states that outright. Also documents that keyword matching is not substring matching: '会议助手' finds 会议小助手 across an inserted character, yet '小助手' does not find 会议小助手 and '红黑榜' does not find 红黑榜小助手, so a shorter fragment is not a safe fallback. The reference doc gains what the +search-user reference has and it lacked: the field contract as a table with types and per-field empty-value behaviour (three distinct semantics -- omitted key, empty string, empty array), a disambiguation section ranking description > has_chatted > is_agent since a single keyword returns eight near-identically named bots, a dedicated fanout section, and a table of rejected flag combinations with the param each one names. Every row of that table and every remaining example was executed before being written down. --- affordance/contact.md | 4 +- shortcuts/contact/contact_search_bot.go | 15 +- shortcuts/contact/contact_search_bot_test.go | 10 +- .../references/lark-contact-search-bot.md | 143 +++++++++++++----- 4 files changed, 120 insertions(+), 52 deletions(-) diff --git a/affordance/contact.md b/affordance/contact.md index 4f2592110c..ffba396acb 100644 --- a/affordance/contact.md +++ b/affordance/contact.md @@ -24,7 +24,7 @@ lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user ``` ## +search-bot -Find bots (apps) the calling user can see, by keyword. Each match returns the bot's open_id plus p2p_chat_id / has_chatted so you can tell whether a conversation with it already exists. A keyword is mandatory — the filters narrow a search, they cannot list bots on their own. +Find bots (apps) by keyword. Each match returns the bot's open_id plus p2p_chat_id / has_chatted so you can tell whether a conversation with it already exists. A keyword is mandatory — neither filter can list bots on its own. `--chat-ids` is not a narrowing filter: it searches inside those chats instead of the tenant-wide set, and surfaces bots a plain `--query` cannot find. ### Avoid when - Looking for a person rather than a bot → use [[+search-user]] @@ -37,7 +37,7 @@ Find bots (apps) the calling user can see, by keyword. Each match returns the bo lark-cli contact +search-bot --query "会议助手" --as user ``` -**Narrow to bots in one chat** +**Search inside one chat** ```bash lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user ``` diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 32131862aa..1f5b93e666 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -85,24 +85,25 @@ type searchBotResponse struct { var ContactSearchBot = common.Shortcut{ Service: "contact", Command: "+search-bot", - Description: "Search bots (apps) visible to the calling user by keyword, optionally narrowed by chat or chat history (requires --as user)", + Description: "Search bots (apps) by keyword — across the tenant, or inside specific chats (requires --as user)", Risk: "read", Scopes: []string{"search:bot"}, AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"}, - {Name: "chat-ids", Desc: "narrow a keyword search to bots in these chats (CSV of chat_id; ≤ 100)"}, + {Name: "chat-ids", Desc: "search inside these chats instead of the tenant-wide set; surfaces bots --query alone cannot find (CSV of chat_id; ≤ 100)"}, {Name: "has-chatted", Type: "bool", Desc: "narrow a keyword search to bots you've chatted with (omit to disable; =false rejected)"}, {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"}, {Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"}, }, Tips: []string{ "Keyword search: lark-cli contact +search-bot --query '会议助手' --as user", - "Narrow to bots in a chat: lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user", + "Search inside a chat: lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user", "Narrow to bots you've chatted with: lark-cli contact +search-bot --query '助手' --has-chatted --as user", "Multi-name fanout: lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user", - "a keyword is required — pass --query or --queries; --chat-ids and --has-chatted only narrow it, and a filter-only request comes back empty rather than as an error.", - "on has_more=true narrow the search (add --chat-ids or --has-chatted, or use a more specific --query) — there is no pagination.", + "A bot inside a chat can be invisible to a plain --query: pass --chat-ids to search that chat instead. Verified: a keyword returning nothing tenant-wide returned the chat's bot once the chat was named.", + "a keyword is required — pass --query or --queries; neither --chat-ids nor --has-chatted can enumerate on its own, and a filter-only request comes back empty rather than as an error.", + "on has_more=true narrow the search with --has-chatted or a more specific --query — there is no pagination.", "enable_join_group=true only means the bot is allowed into chats. Adding it needs the app's cli_ app_id, which this command does not return: the ou_ open_id here is rejected by the chat-member APIs and there is no open_id → app_id lookup. Do not claim a bot was added on the strength of this flag.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -150,7 +151,7 @@ func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error // requirement. Naming only --query would tell an agent that --queries is not a // way out, which it is. func botSearchKeywordRequiredError() error { - return common.ValidationErrorf("specify --query or --queries: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API answers a filter-only request with an empty list)"). + return common.ValidationErrorf("specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)"). WithParams( errs.InvalidParam{Name: "--query", Reason: "required unless --queries is given"}, errs.InvalidParam{Name: "--queries", Reason: "required unless --query is given"}, @@ -330,7 +331,7 @@ func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) }) if respData.HasMore && isHumanReadableFormat(runtime.Format) { fmt.Fprintln(runtime.IO().ErrOut, - "\nhint: more matches exist; narrow the search (e.g. add --chat-ids, --has-chatted, or a more specific --query)") + "\nhint: more matches exist; narrow with --has-chatted or a more specific --query") } return nil } diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index af4a7935b5..286ae7400c 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -107,7 +107,7 @@ func TestValidateBotSearchErrors(t *testing.T) { { name: "keyword missing", wantParams: []string{"--query", "--queries"}, - wantMessage: "specify --query or --queries: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API answers a filter-only request with an empty list)", + wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)", }, { name: "query over 50 characters", @@ -178,13 +178,13 @@ func TestValidateBotSearchErrors(t *testing.T) { name: "chat ids without a keyword", flags: map[string]string{"chat-ids": "oc_a"}, wantParams: []string{"--query", "--queries"}, - wantMessage: "specify --query or --queries: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API answers a filter-only request with an empty list)", + wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)", }, { name: "has chatted without a keyword", flags: map[string]string{"has-chatted": "true"}, wantParams: []string{"--query", "--queries"}, - wantMessage: "specify --query or --queries: --chat-ids and --has-chatted only narrow a keyword search, they cannot enumerate bots on their own (the API answers a filter-only request with an empty list)", + wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)", }, } @@ -506,7 +506,7 @@ func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) { t.Errorf("pretty output exposed %q: %s", genericField, stdout.String()) } } - wantHint := "\nhint: more matches exist; narrow the search (e.g. add --chat-ids, --has-chatted, or a more specific --query)\n" + wantHint := "\nhint: more matches exist; narrow with --has-chatted or a more specific --query\n" if stderr.String() != wantHint { t.Fatalf("pretty stderr: got %q, want %q", stderr.String(), wantHint) } @@ -525,7 +525,7 @@ func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) { t.Errorf("table output missing %q: %s", field, stdout.String()) } } - wantHint := "\nhint: more matches exist; narrow the search (e.g. add --chat-ids, --has-chatted, or a more specific --query)\n" + wantHint := "\nhint: more matches exist; narrow with --has-chatted or a more specific --query\n" if stderr.String() != wantHint { t.Fatalf("table stderr: got %q, want %q", stderr.String(), wantHint) } diff --git a/skills/lark-contact/references/lark-contact-search-bot.md b/skills/lark-contact/references/lark-contact-search-bot.md index ae22275da5..d0ac377a4c 100644 --- a/skills/lark-contact/references/lark-contact-search-bot.md +++ b/skills/lark-contact/references/lark-contact-search-bot.md @@ -4,28 +4,46 @@ ## 适用范围 -- ✅ 已知机器人名字(或名字片段)想找出它的 open_id -- ✅ 一次解析多个机器人名字(`--queries`,最多 20 个词) -- ✅ 想知道某个群里有哪些机器人、或自己和哪些机器人聊过天 —— 但都要配关键词 +- ✅ 已知机器人名字想找出它的 open_id +- ✅ 一次解析多个机器人名字(`--queries`,去重后最多 20 个词) +- ✅ 想知道某个群里有哪些机器人(`--chat-ids`,这是唯一能找到群内机器人的办法,见下) +- ✅ 想知道自己和哪些机器人聊过天 —— 但要配关键词 - ❌ 不给关键词、只想列出全部可见机器人 → 接口不支持,见下文 - ❌ 已知 open_id 想给机器人发消息 → 直接走 `lark-im`,不经过本命令 ## 关键 flag -**必须给关键词**:`--query` 或 `--queries` 至少一个。`--chat-ids` 和 `--has-chatted` 只能收窄关键词搜索,不能独立枚举 —— 服务端对纯 filter 请求返回**空列表而不是报错**,所以 CLI 提前拦住,避免"没有这种机器人"的假结论。 +**必须给关键词**:`--query` 或 `--queries` 至少一个。两个 filter 都不能独立枚举 —— 服务端对纯 filter 请求返回**空列表而不是报错**,所以 CLI 提前拦住,避免"没有这种机器人"的假结论。 | Flag | 作用 | |---|---| | `--query ` | 关键词,≤ 50 字符(按字符计,不是字节) | -| `--queries ` | 多个关键词并行搜,**最多 20 个唯一词**,每词 ≤ 50 字符;与 `--query` 互斥;输出 shape 不同(见下) | -| `--chat-ids ` | 只在这些群里找,**最多 100 个去重后的 chat_id** | -| `--has-chatted` | 只要和自己有单聊会话的;显式传 `=false` 会报错 —— 不传等于不过滤 | +| `--queries ` | 多个关键词并行搜,**去重后最多 20 个词**,每词 ≤ 50 字符;与 `--query` 互斥;输出 shape 不同(见下) | +| `--chat-ids ` | **改变搜索范围**到这些群内,不是收窄(见下);**最多 100 个去重后的 chat_id** | +| `--has-chatted` | 收窄到和自己有单聊会话的;显式传 `=false` 会报错 —— 不传等于不过滤 | | `--page-size ` | 每次返回条数,1–30(服务端上限就是 30) | +### `--chat-ids` 是换范围,不是过滤 + +**群内的机器人在租户级搜索里可能完全不可见,只有指定群才搜得到。** 所以 `--chat-ids` 的结果不是无过滤结果的子集,它能让你找到 `--query` 单独怎么都搜不出来的机器人。实测: + +| query | 无 `--chat-ids` | 指定 3 个工程群 | +|---|---|---| +| `红黑榜` | 0 条 | 红黑榜小助手 | +| `助` | 尚磊的智能助手 ×2 | 红黑榜小助手(**完全不同的结果集**) | + +对照 `--has-chatted` 才是真收窄:同一个 `助` 从 2 条降到 1 条,且是原结果里的同一个 open_id。 + +推论:**搜不到某个机器人时,先想想它是不是只存在于某个群里**,把群 ID 传进来再搜,而不是换关键词。 + +### 关键词匹配不是子串匹配 + +服务端的匹配规则未公开,黑盒实测能确定的是**它不是简单子串**:`会议助手` 能匹配到「会议**小**助手」(中间插字),但 `小助手` 匹配不到「会议小助手」,`红黑榜` 也匹配不到「红黑榜小助手」。所以搜不中时优先换更完整的名字,而不是截更短的片段。 + ### 输入归一化规则 -- `--queries`:去首尾空白 → 丢弃空项 → 大小写敏感的精确去重 → 保留首次出现顺序。**20 个上限是按去重后的数量算的**,`'助手,助手,助手'` 只发一次请求。 -- `--chat-ids`:接受裸 `oc_...`,也接受包含 `oc_...` 的飞书 / Lark 群链接 —— 链接会先归一化成裸 chat_id,**再**按 chat_id 去重,**最后**才检查 100 上限。所以同一个群写成链接和裸 ID 各传一遍,只算一个。 +- `--queries`:去首尾空白 → 丢弃空项 → 大小写敏感的精确去重 → 保留首次出现顺序。**20 个上限是按去重后的数量算的**,`'助手,助手,助手'` 只发一次请求,21 项里有 1 项重复也能通过。 +- `--chat-ids`:接受裸 `oc_...`,也接受包含 `oc_...` 的飞书 / Lark 群链接 —— 链接会先归一化成裸 chat_id,**再**按 chat_id 去重,**最后**才检查 100 上限。所以同一个群写成链接和裸 ID 各传一遍只算一个,101 个相同 ID 也能通过。 ## 常用例子 @@ -33,8 +51,8 @@ # 按名字找,拿 open_id lark-cli contact +search-bot --query '会议助手' --as user -# 只在某个群里找 -lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user +# 找某个群里的机器人(租户级搜不到它时的唯一办法) +lark-cli contact +search-bot --query '助' --chat-ids oc_xxx --as user # 只要聊过天的 lark-cli contact +search-bot --query '助手' --has-chatted --as user @@ -43,9 +61,54 @@ lark-cli contact +search-bot --query '助手' --has-chatted --as user lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user ``` +会被拒绝的写法(都是 typed validation error,`type: validation` / `subtype: invalid_argument`,退出码 2): + +| 写法 | 错误信封点名 | +|---|---| +| 只给 filter 不给关键词,如 `--has-chatted` 单独用 | `params: ["--query", "--queries"]` | +| `--query` 与 `--queries` 同传 | `params: ["--query", "--queries"]`,message 说 mutually exclusive | +| 去重后超过 20 个词 | `param: "--queries"`,`must be at most 20 entries (got N)` | +| `--queries ',,,'` 解析不出词 | `param: "--queries"`,`no valid query parsed from ",,,"` | +| `--query` 超过 50 字符 | `param: "--query"` | +| `--has-chatted=false` 显式传 | `param: "--has-chatted"` | +| `--chat-ids` 里有非 `oc_` 开头的值 | `param: "--chat-ids"` | + +## 批量并行查询 (fanout) + +```bash +lark-cli contact +search-bot --queries '会议助手,日报,审批' --as user +``` + +- 每行 bot 带 `matched_query`,标识来自哪个词 +- `queries[]` 每个去重后的词一条 `{query, error?, has_more, notice?}` +- 个别词失败不影响其他词,命令仍成功退出;**全部失败才报错**,且首个失败的分类(HTTP 状态 / API code)会透传 +- `--chat-ids` / `--has-chatted` 会作用到每一个词上 +- 并发上限 5;实测 20 个词约 4 秒 + +约束:去重后最多 20 个词、每词 ≤ 50 字符、全空 csv(`,,,`)报错。 + +## 多条命中怎么选 + +机器人重名比人更严重:实测 `--query '会议助手'` 返回 8 条,名字是「会议助手 / 会议室助手 / 会议小助手 / 会议群助手 / 视频会议问卷助手 / 会议室录音授权助手 / 飞书会议室告警助手 / 会议权限控制小助手」,光看名字分不出该用哪个。 + +后续操作若有副作用(拉群、发消息等),把候选列给用户挑,**不要擅自选第一条**。 + +筛选信号(可信度从高到低): + +1. `description` —— 机器人的一句话简介,最能区分功能。名字相近时几乎只能靠它 +2. `has_chatted` —— 你用过的那个,通常就是要找的 +3. `is_agent` —— 区分智能体和普通推送机器人 +4. `enable_join_group` —— 只在需要把它拉进群时才有筛选价值(注意它是空承诺,见下) + +```bash +# 按简介精筛 +lark-cli contact +search-bot --query '会议助手' \ + --jq '.data.bots[] | select(.description | contains("<功能关键词>"))' --as user +``` + ## 没有分页 -和 `+search-user` 一致:没有 `--page-token`,输出也不给 `page_token`。`has_more=true` 时**收窄搜索**(补 `--chat-ids` / `--has-chatted`,或换更具体的关键词),而不是翻页。 +和 `+search-user` 一致:没有 `--page-token`,输出也不给 `page_token`。`has_more=true` 时用 `--has-chatted` 或更具体的关键词收窄,而不是翻页 —— 注意 `--chat-ids` 起不到收窄作用。 ## 注意事项 @@ -56,38 +119,42 @@ lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' ## 输出字段 contract -单关键词模式: +`data.bots[]` 每个机器人一条: -``` -bots[] 每个机器人一条 - open_id ou_ 开头,机器人的 open_id - name 从 display_info 第一行解析 - description 从 display_info 第二行解析(可能为空,此时省略该字段) - p2p_chat_id 与调用者的单聊会话;没有则为空字符串(字段仍存在) - has_chatted p2p_chat_id 是否非空 - enable_join_group 是否允许被拉进群聊 - is_agent 是否是智能体 - tenant_id 租户标识(可能省略) - match_segments[] 命中的关键词片段;无命中时为 [] -has_more 还有更多命中 -notice 服务端补充说明(可能省略) -``` +| 字段 | 类型 | 说明 | 空值行为 | +|---|---|---|---| +| `open_id` | string | `ou_` 开头的机器人 open_id,稳定标识 | 始终非空 | +| `name` | string | 从 `display_info` 第一行解析;解析不出时兜底为 `open_id` | 始终非空 | +| `description` | string (optional) | 从 `display_info` 第二行解析的一句话简介;**同名机器人主要靠它区分** | 空时**字段不出现** | +| `p2p_chat_id` | string | 与调用者的单聊会话(`oc_...`),可作为接受 `--chat-id` 的 IM 命令的输入 | 空时**字段仍在,值为空串** | +| `has_chatted` | bool | `p2p_chat_id != ""` 的派生字段 | — | +| `enable_join_group` | bool | 是否允许被拉进群聊(但你拉不动,见上) | — | +| `is_agent` | bool | 是否是智能体 | — | +| `tenant_id` | string (optional) | 租户标识 | 空时**字段不出现** | +| `match_segments` | string[] | 关键词命中的字符串片段,用于高亮展示 | 无命中时是 `[]`,不是 null | + +顶层:`has_more`(bool)、`notice`(string, optional,空时不出现)。 + +**三种空值语义各不相同**,下游要分别处理:`description` / `tenant_id` 会整个消失,`p2p_chat_id` 保留空串,`match_segments` 保留空数组。 ### `--queries` 模式额外字段 -顶层 shape 变成 `{bots[], queries[], notice?}` —— **没有顶层 `has_more`**,`has_more` 只在 `queries[]` 逐词给出。 +顶层 shape 变成 `{bots[], queries[], notice?}` —— **没有顶层 `has_more`**。 -``` -bots[] 比单关键词模式多一个字段: - matched_query 这一行是被哪个关键词命中的 -queries[] 按去重后的输入顺序,每个词一条: - query 关键词原文 - error 该词失败的原因(成功时省略) - has_more 该词是否还有更多命中 - notice 该词的服务端补充说明(可能省略) -``` +`data.bots[]` 每条多一个字段: -个别词失败不影响其他词,命令仍然成功退出,失败原因在对应的 `queries[].error` 里;**只有全部词都失败才报错**,且首个失败的分类(HTTP 状态 / API code)会透传出来。 +| 字段 | 类型 | 说明 | +|---|---|---| +| `matched_query` | string | 这一行是被哪个关键词命中的 | + +`data.queries[]` 按去重后的输入顺序,每个词一条: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `query` | string | 关键词原文 | +| `error` | string (optional) | 该词失败的原因;成功时不出现 | +| `has_more` | bool | 该词是否还有更多命中 | +| `notice` | string (optional) | 该词的服务端补充说明 | ### 各 `--format` 的差异 From 9f536d68595622e76803167cd60bed1c13590f77 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 16:35:16 +0800 Subject: [PATCH 17/27] fix(contact): stop dropping notice and per-query failures outside json Two counter-examples failed on the previous head, and both were real. Only the json envelope carried data.notice. Probing all five formats showed ndjson, csv, table and pretty each dropped it with nothing written anywhere, so a caller reading a truncated or incomplete result had no way to learn it was partial -- the notice is precisely how the server says "results are incomplete" or "the query was truncated". Those formats now emit it on stderr, keeping stdout data-only so pipes are unaffected. json keeps it in the envelope and gets no duplicate. The fanout summary said how many queries failed but never which or why, and queries[].error likewise only exists in the json envelope. A caller on csv saw "1 failed" and could not recover the keyword or the reason. Each failed query and each per-query notice now gets its own stderr line. Three existing format tests pinned the old stderr contract and failed on the change, which is what they are for; their expectations moved to the new one. The two counter-examples became permanent tests covering all five formats. Separately, this branch had leaked tenant data into shipped docs: the reference named this tenant's internal bots, including one carrying a person's name, to illustrate the --chat-ids scoping and the duplicate-name problem. Those passages now state the observed behaviour without naming anything. The live E2E had the matching problem -- it required at least one bot matching a hard-coded keyword, the same tenant dependency that coverage.md records as the reason +search-user has no live coverage. It now asserts envelope shape plus per-row invariants over whatever the tenant returns, so zero rows passes, and a second case pins the filter-only rejection, which needs no tenant data at all. --- shortcuts/contact/contact_search_bot.go | 14 ++++ .../contact/contact_search_bot_fanout.go | 14 ++++ .../contact/contact_search_bot_fanout_test.go | 31 +++++++++ shortcuts/contact/contact_search_bot_test.go | 65 ++++++++++++++++--- .../references/lark-contact-search-bot.md | 32 +++++---- .../contact_search_bot_workflow_test.go | 49 ++++++++++++-- tests/cli_e2e/contact/coverage.md | 5 +- 7 files changed, 178 insertions(+), 32 deletions(-) diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 1f5b93e666..aa596b56ae 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -291,6 +291,17 @@ func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, e return req, nil } +// botSearchStdoutCarriesNotice reports whether the chosen format puts the +// server's notice into stdout. Only the json envelope does; pretty, table, csv +// and ndjson all render rows only, so a notice ("results are incomplete", "the +// query was truncated") would vanish and the caller would read a partial result +// as a complete one. For those formats the notice goes to stderr, which keeps +// stdout pipe-clean. A --jq expression can still project the notice away, but +// that is the caller's explicit choice. +func botSearchStdoutCarriesNotice(format string) bool { + return format == "json" || format == "" +} + func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) error { body, err := buildBotSearchBody(runtime) if err != nil { @@ -329,6 +340,9 @@ func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) } output.PrintTable(w, prettyBotRows(bots)) }) + if respData.Notice != "" && !botSearchStdoutCarriesNotice(runtime.Format) { + fmt.Fprintf(runtime.IO().ErrOut, "\nnotice: %s\n", respData.Notice) + } if respData.HasMore && isHumanReadableFormat(runtime.Format) { fmt.Fprintln(runtime.IO().ErrOut, "\nhint: more matches exist; narrow with --has-chatted or a more specific --query") diff --git a/shortcuts/contact/contact_search_bot_fanout.go b/shortcuts/contact/contact_search_bot_fanout.go index a27c6d5f7f..2d71f68370 100644 --- a/shortcuts/contact/contact_search_bot_fanout.go +++ b/shortcuts/contact/contact_search_bot_fanout.go @@ -198,6 +198,20 @@ func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total bots; %d failed, %d with has_more\n", len(queries), len(resp.Bots), failed, hasMoreCount) } + // The counts above say how many queries failed but not which, and only the + // json envelope carries queries[].error / queries[].notice. Without this an + // agent reading csv or a table sees "1 failed" with no way to learn the + // keyword or the reason, and a notice disappears entirely. + if !botSearchStdoutCarriesNotice(runtime.Format) { + for _, qs := range resp.Queries { + if qs.Error != "" { + fmt.Fprintf(runtime.IO().ErrOut, "failed: %q — %s\n", qs.Query, qs.Error) + } + if qs.Notice != "" { + fmt.Fprintf(runtime.IO().ErrOut, "notice: %q — %s\n", qs.Query, qs.Notice) + } + } + } return nil } diff --git a/shortcuts/contact/contact_search_bot_fanout_test.go b/shortcuts/contact/contact_search_bot_fanout_test.go index 86ec1bd357..b3de8b79e4 100644 --- a/shortcuts/contact/contact_search_bot_fanout_test.go +++ b/shortcuts/contact/contact_search_bot_fanout_test.go @@ -559,3 +559,34 @@ func TestBotFanoutDryRunPreviewsOneRequestPerKeyword(t *testing.T) { t.Errorf("previewed keywords: got %v, want [会议 日报]", seen) } } + +// The summary counts how many queries failed but never says which or why, and +// only json carries queries[].error. Without a per-query line on stderr an agent +// reading csv sees "1 failed" and cannot recover the keyword or the reason. +func TestBotFanoutFailedQueryIsNamedOnStderr(t *testing.T) { + for _, format := range []string{"csv", "table", "pretty", "ndjson"} { + t.Run(format, func(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + broken := botSearchStub(botSearchURL, "") + broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) } + broken.Status = 500 + broken.Body = map[string]interface{}{"reason": "boom"} + registry.Register(broken) + okStub := botSearchStub(botSearchURL, "") + okStub.Reusable = true + registry.Register(okStub) + + if err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--queries", "会议,日报", "--format", format, "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("one failing query must not fail the batch: %v", err) + } + for _, want := range []string{"日报", "500"} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("%s: stderr must name the failed query and its reason (missing %q)\nstderr:\n%s", + format, want, stderr.String()) + } + } + }) + } +} diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 286ae7400c..181e3432c6 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -506,9 +506,15 @@ func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) { t.Errorf("pretty output exposed %q: %s", genericField, stdout.String()) } } - wantHint := "\nhint: more matches exist; narrow with --has-chatted or a more specific --query\n" - if stderr.String() != wantHint { - t.Fatalf("pretty stderr: got %q, want %q", stderr.String(), wantHint) + // pretty stdout carries rows only, so stderr has to carry both the server + // notice and the pagination hint. + for _, want := range []string{ + "notice: The query is too long and has been truncated to the first 50 characters for search.", + "hint: more matches exist; narrow with --has-chatted or a more specific --query", + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("pretty stderr missing %q: %q", want, stderr.String()) + } } } @@ -525,9 +531,15 @@ func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) { t.Errorf("table output missing %q: %s", field, stdout.String()) } } - wantHint := "\nhint: more matches exist; narrow with --has-chatted or a more specific --query\n" - if stderr.String() != wantHint { - t.Fatalf("table stderr: got %q, want %q", stderr.String(), wantHint) + // table stdout carries rows only, so stderr has to carry both the server + // notice and the pagination hint. + for _, want := range []string{ + "notice: The query is too long and has been truncated to the first 50 characters for search.", + "hint: more matches exist; narrow with --has-chatted or a more specific --query", + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("table stderr missing %q: %q", want, stderr.String()) + } } } @@ -546,8 +558,13 @@ func TestBotSearchCSVAndNDJSONExposeFullFieldsWithoutPaginationHint(t *testing.T t.Errorf("%s output missing %q: %s", format, field, stdout.String()) } } - if stderr.Len() != 0 { - t.Fatalf("%s stderr: got %q, want empty", format, stderr.String()) + // Machine formats get no pagination hint, but the notice still has to + // reach the caller somewhere, and stdout must stay data-only. + if strings.Contains(stderr.String(), "hint: more matches exist") { + t.Fatalf("%s must not emit the pagination hint: %q", format, stderr.String()) + } + if !strings.Contains(stderr.String(), "notice: The query is too long") { + t.Fatalf("%s dropped the notice: %q", format, stderr.String()) } }) } @@ -617,3 +634,35 @@ func TestDecodeBotSearchAPIDataMarshalFailureTyped(t *testing.T) { t.Fatalf("problem: %+v, ok=%v", problem, ok) } } + +// Only the json envelope carries data.notice. If the other formats dropped it +// silently, a caller would read a truncated or incomplete result as a complete +// one, so every non-json format has to surface it on stderr instead. +func TestBotSearchNoticeReachesCallerInEveryFormat(t *testing.T) { + const notice = "The query is too long and has been truncated to the first 50 characters for search." + for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} { + t.Run(format, func(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + registry.Register(botSearchStub(botSearchURL+"?page_size=20", "")) + if err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--query", "助手", "--format", format, "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Contains(stdout.String(), notice) { + if format != "json" { + t.Fatalf("%s should not carry the notice in stdout: %s", format, stdout.String()) + } + return + } + if !strings.Contains(stderr.String(), notice) { + t.Fatalf("%s dropped the notice entirely\nstdout:\n%s\nstderr:\n%s", + format, stdout.String(), stderr.String()) + } + // stdout stays pipe-clean: the notice must not be mixed into the rows. + if format == "csv" && strings.Contains(stdout.String(), "notice") { + t.Fatalf("csv stdout must stay data-only: %s", stdout.String()) + } + }) + } +} diff --git a/skills/lark-contact/references/lark-contact-search-bot.md b/skills/lark-contact/references/lark-contact-search-bot.md index d0ac377a4c..0389b84cda 100644 --- a/skills/lark-contact/references/lark-contact-search-bot.md +++ b/skills/lark-contact/references/lark-contact-search-bot.md @@ -25,20 +25,18 @@ ### `--chat-ids` 是换范围,不是过滤 -**群内的机器人在租户级搜索里可能完全不可见,只有指定群才搜得到。** 所以 `--chat-ids` 的结果不是无过滤结果的子集,它能让你找到 `--query` 单独怎么都搜不出来的机器人。实测: +**群内的机器人在租户级搜索里可能完全不可见,只有指定群才搜得到。** 所以 `--chat-ids` 的结果不是无过滤结果的子集,它能让你找到 `--query` 单独怎么都搜不出来的机器人。实测过的两种情形: -| query | 无 `--chat-ids` | 指定 3 个工程群 | -|---|---|---| -| `红黑榜` | 0 条 | 红黑榜小助手 | -| `助` | 尚磊的智能助手 ×2 | 红黑榜小助手(**完全不同的结果集**) | +- 某个关键词在租户级搜索返回 **0 条**,把该机器人所在的群传给 `--chat-ids` 后就能搜到 +- 同一个关键词,加 `--chat-ids` 前后返回的是**两组互不相交的结果**,而不是子集 -对照 `--has-chatted` 才是真收窄:同一个 `助` 从 2 条降到 1 条,且是原结果里的同一个 open_id。 +对照 `--has-chatted` 才是真收窄:同一个关键词加上它之后结果条数下降,且剩下的 open_id 是原结果里的。 推论:**搜不到某个机器人时,先想想它是不是只存在于某个群里**,把群 ID 传进来再搜,而不是换关键词。 ### 关键词匹配不是子串匹配 -服务端的匹配规则未公开,黑盒实测能确定的是**它不是简单子串**:`会议助手` 能匹配到「会议**小**助手」(中间插字),但 `小助手` 匹配不到「会议小助手」,`红黑榜` 也匹配不到「红黑榜小助手」。所以搜不中时优先换更完整的名字,而不是截更短的片段。 +服务端的匹配规则未公开,黑盒实测能确定的是**它不是简单子串**:一个完整名字能匹配到中间多插了一个字的机器人,但反过来拿该名字的**后半段**去搜就匹配不到同一个机器人。所以搜不中时优先换更完整的名字,而不是截更短的片段。 ### 输入归一化规则 @@ -89,7 +87,7 @@ lark-cli contact +search-bot --queries '会议助手,日报,审批' --as user ## 多条命中怎么选 -机器人重名比人更严重:实测 `--query '会议助手'` 返回 8 条,名字是「会议助手 / 会议室助手 / 会议小助手 / 会议群助手 / 视频会议问卷助手 / 会议室录音授权助手 / 飞书会议室告警助手 / 会议权限控制小助手」,光看名字分不出该用哪个。 +机器人重名比人更严重:一个业务领域的关键词往往命中十来个名字高度相似的机器人(同一个词根 + 不同修饰),光看名字分不出该用哪个。 后续操作若有副作用(拉群、发消息等),把候选列给用户挑,**不要擅自选第一条**。 @@ -115,7 +113,7 @@ lark-cli contact +search-bot --query '会议助手' \ - **`enable_join_group=true` 不等于你能把它拉进群。** 加机器人进群需要应用的 `cli_` 开头 app_id,本命令不返回;拿这里的 `ou_` open_id 去调加群接口,服务端会把它放进 `invalid_id_list`,而且没有 open_id → app_id 的查询接口。看到这个字段为真时不要声称已加入成功。 - **`meta_data.chat_id` 的官方文档写错了。** 文档说是"机器人所属的群聊 ID",实际是**调用者与该机器人的单聊会话**。本命令按后者投影成 `p2p_chat_id`。 - **ID 类型只出 `open_id`。** 接口本身支持 `user_id_type`(实测 `union_id` 会返回 `on_...`),但本命令有固定输出结构,字段名会随之说谎,所以不暴露该参数;确实要 union_id / user_id 时走 `lark-cli api` 直调。 -- **`notice` 原样透出。** 服务端用它说明本次搜索的额外情况(如结果不全),不要忽略。 +- **`notice` 一定会到达调用方,但位置随格式变。** 服务端用它说明本次搜索的额外情况(如结果不全、query 被截断)。`json` 放在信封的 `data.notice`;其余格式的 stdout 只有数据行,所以 notice 写到 **stderr**(`notice: ...`),保证 stdout 仍可直接进管道。扇出模式下逐词的 notice 和失败原因同样会逐行写 stderr(`notice: "词" — ...` / `failed: "词" — ...`)。 ## 输出字段 contract @@ -158,12 +156,12 @@ lark-cli contact +search-bot --query '会议助手' \ ### 各 `--format` 的差异 -| format | stdout | 扇出计数写 stderr | -|---|---|---| -| `pretty` | 摘要表:单关键词 6 列;`--queries` 模式多一列 `matched_query`,共 7 列 | 是 | -| `table` | 完整结果字段 | 是 | -| `csv` | 完整结果字段 | 是 | -| `json` | 完整信封 | 否 | -| `ndjson` | 每行一条完整记录 | 否 | +| format | stdout | notice / 逐词失败 | 分页提示 | 扇出计数 | +|---|---|---|---|---| +| `json` | 完整信封,含 `notice` 与 `queries[]` | 在 stdout 信封里 | 无 | 无 | +| `ndjson` | 每行一条记录,无信封 | 写 stderr | 无 | 无 | +| `csv` | 完整结果字段 | 写 stderr | 无 | 写 stderr | +| `table` | 完整结果字段 | 写 stderr | 写 stderr | 写 stderr | +| `pretty` | 摘要表:单关键词 6 列;`--queries` 模式多一列 `matched_query`,共 7 列 | 写 stderr | 写 stderr | 写 stderr | -扇出计数那行形如 `2 queries, 3 total bots; 1 failed, 0 with has_more`,只在 `pretty` / `table` / `csv` 下输出。用 stdout 做管道时选 `json` 或 `ndjson`,不会混入这行。 +**stdout 在任何格式下都只有数据**,提示类信息一律走 stderr,所以管道安全。扇出计数那行形如 `2 queries, 3 total bots; 1 failed, 0 with has_more`。 diff --git a/tests/cli_e2e/contact/contact_search_bot_workflow_test.go b/tests/cli_e2e/contact/contact_search_bot_workflow_test.go index 3006148823..705eee2cbc 100644 --- a/tests/cli_e2e/contact/contact_search_bot_workflow_test.go +++ b/tests/cli_e2e/contact/contact_search_bot_workflow_test.go @@ -5,6 +5,7 @@ package contact import ( "context" + "strings" "testing" "time" @@ -13,6 +14,15 @@ import ( "github.com/tidwall/gjson" ) +// TestContactSearchBotWorkflowAsUser proves the live round-trip without assuming +// anything about the tenant's bot inventory. An earlier version required at least +// one match for a hard-coded keyword, which is the tenant dependency that kept +// +search-user out of live coverage (see coverage.md): a tenant with no bot +// matching that word would fail the suite for no reason of ours. +// +// What is tenant-independent and still worth pinning: the command authenticates, +// the server accepts the request, and the envelope keeps its shape. The field +// assertions run over whatever rows came back, so zero rows is a pass. func TestContactSearchBotWorkflowAsUser(t *testing.T) { clie2e.SkipWithoutUserToken(t) @@ -27,11 +37,40 @@ func TestContactSearchBotWorkflowAsUser(t *testing.T) { result.AssertStdoutStatus(t, true) bots := gjson.Get(result.Stdout, "data.bots") - require.True(t, bots.IsArray(), "data.bots must be an array; stdout:\n%s", result.Stdout) + require.True(t, bots.IsArray(), "data.bots must be an array even when empty; stdout:\n%s", result.Stdout) require.True(t, gjson.Get(result.Stdout, "data.has_more").Exists(), "data.has_more must be present; stdout:\n%s", result.Stdout) - botItems := bots.Array() - require.NotEmpty(t, botItems, "data.bots must contain at least one bot; stdout:\n%s", result.Stdout) - for _, bot := range botItems { - require.NotEmpty(t, bot.Get("open_id").String(), "every bot must carry open_id; stdout:\n%s", result.Stdout) + + for _, bot := range bots.Array() { + openID := bot.Get("open_id").String() + require.NotEmpty(t, openID, "every bot must carry open_id; stdout:\n%s", result.Stdout) + require.True(t, strings.HasPrefix(openID, "ou_"), + "bot ids are open_ids; stdout:\n%s", result.Stdout) + require.True(t, bot.Get("p2p_chat_id").Exists(), + "p2p_chat_id must be present even when empty; stdout:\n%s", result.Stdout) + require.True(t, bot.Get("match_segments").IsArray(), + "match_segments must be an array, never null; stdout:\n%s", result.Stdout) + } +} + +// A filter without a keyword is rejected locally, so this costs no API call and +// holds in any tenant: it pins the contract that neither filter can enumerate. +func TestContactSearchBotRejectsFilterOnlyAsUser(t *testing.T) { + clie2e.SkipWithoutUserToken(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"contact", "+search-bot", "--has-chatted", "--format", "json"}, + DefaultAs: "user", + }) + require.NoError(t, err) + require.NotEqual(t, 0, result.ExitCode, "a filter-only request must not succeed; stderr:\n%s", result.Stderr) + require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) + + var named []string + for _, p := range gjson.Get(result.Stderr, "error.params").Array() { + named = append(named, p.Get("name").String()) } + require.ElementsMatch(t, []string{"--query", "--queries"}, named, + "the error must name both ways to supply a keyword; stderr:\n%s", result.Stderr) } diff --git a/tests/cli_e2e/contact/coverage.md b/tests/cli_e2e/contact/coverage.md index 2e1ee61298..d22d1d4508 100644 --- a/tests/cli_e2e/contact/coverage.md +++ b/tests/cli_e2e/contact/coverage.md @@ -8,7 +8,8 @@ ## Summary - TestContact_LookupWorkflowAsUser: proves the user lookup workflow through `get self as user` and `get self by open id as user`; reads the current user first and round-trips the returned `open_id` back into `+get-user`. - TestContact_LookupWorkflowAsBot: proves bot lookup through `discover user via api as bot` and `get user by open id as bot`; the raw API discovery step is fixture setup only and does not affect the domain denominator. -- TestContactSearchBotWorkflowAsUser: proves live bot search as user; validates `bots[]`, `has_more`, and a non-empty bot `open_id`. +- TestContactSearchBotWorkflowAsUser: proves live bot search as user; validates the envelope shape (`bots[]` is an array, `has_more` present) and, for whatever rows the tenant returns, that `open_id` is an `ou_` id, `p2p_chat_id` is present even when empty, and `match_segments` is never null. Deliberately does not require a minimum row count: the assertions must hold in a tenant with no matching bot. +- TestContactSearchBotRejectsFilterOnlyAsUser: pins that `--has-chatted` without a keyword is rejected as a typed validation error naming both `--query` and `--queries`. Rejected locally, so it needs no tenant data and issues no API call. - Blocked area: `contact +search-user` did not reliably return the current user in UAT even when queried with self-derived identifiers, so it remains uncovered rather than being counted from a flaky tenant-dependent assertion. ## Command Table @@ -16,5 +17,5 @@ | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | | ✓ | contact +get-user | shortcut | contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self by open id as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsBot/get user by open id as bot | self lookup; `--user-id ` | | -| ✓ | contact +search-bot | shortcut | contact_search_bot_workflow_test.go::TestContactSearchBotWorkflowAsUser | `--query `; `--format json`; user identity | | +| ✓ | contact +search-bot | shortcut | contact_search_bot_workflow_test.go::TestContactSearchBotWorkflowAsUser; contact_search_bot_workflow_test.go::TestContactSearchBotRejectsFilterOnlyAsUser | `--query `; `--has-chatted` alone (rejected); `--format json`; user identity | tenant-independent: no minimum row count asserted | | ✕ | contact +search-user | shortcut | | none | UAT did not reliably return the current user for self-derived queries, so stable write-after-read style proof is not available | From af8728c7824cb02c29e009973ae0001e63374248 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 16:39:21 +0800 Subject: [PATCH 18/27] test(contact): replace tenant bot names in display_info fixtures The display_info table was built from real observed responses, so the fixtures carried this tenant's internal bot names -- one of them a person's name -- into a shipped repository. Swapped for synthetic strings that keep each case's structure intact: whole name highlighted, two highlighted runs split by a plain character, highlight at the end followed by a trailing newline so line two exists but is empty, and a single highlighted character mid-name. Those shapes are what the parser has to handle; the words never mattered. --- shortcuts/contact/contact_search_bot_test.go | 41 +++++++++++--------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 181e3432c6..94cc454cfe 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -319,11 +319,16 @@ func TestParseBotDisplayInfo(t *testing.T) { wantDescription string wantSegments []string }{ - {name: "single live match", raw: "会议助手\n推送未接会议消息提醒", openID: "ou_a", wantName: "会议助手", wantDescription: "推送未接会议消息提醒", wantSegments: []string{"会议助手"}}, - {name: "multiple live matches", raw: "会议助手\n你的专属会议室小管家", openID: "ou_b", wantName: "会议室助手", wantDescription: "你的专属会议室小管家", wantSegments: []string{"会议", "助手"}}, - {name: "empty live description", raw: "尚磊的智能助手\n", openID: "ou_c", wantName: "尚磊的智能助手", wantSegments: []string{"助手"}}, - {name: "mid-name live match", raw: "红黑榜小手\n每天定时发送阻塞红黑榜Bug看板", openID: "ou_d", wantName: "红黑榜小助手", wantDescription: "每天定时发送阻塞红黑榜Bug看板", wantSegments: []string{"助"}}, - {name: "no newline", raw: "会议助手", openID: "ou_e", wantName: "会议助手", wantSegments: []string{}}, + // Whole name highlighted, description on line two. + {name: "whole name highlighted", raw: "甲乙丙\n一句话简介", openID: "ou_a", wantName: "甲乙丙", wantDescription: "一句话简介", wantSegments: []string{"甲乙丙"}}, + // Two highlighted runs split by a plain character: stripping tags has to + // rejoin them into one name. + {name: "two highlighted runs", raw: "甲乙\n另一句简介", openID: "ou_b", wantName: "甲乙丁丙", wantDescription: "另一句简介", wantSegments: []string{"甲乙", "丙"}}, + // Highlight at the end plus a trailing newline: line two exists but is empty. + {name: "trailing newline empty description", raw: "戊己的庚辛\n", openID: "ou_c", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}}, + // Single highlighted character in the middle of the name. + {name: "mid-name highlight", raw: "壬癸丑\n第二行简介", openID: "ou_d", wantName: "壬癸子丑", wantDescription: "第二行简介", wantSegments: []string{"子"}}, + {name: "no newline", raw: "寅卯", openID: "ou_e", wantName: "寅卯", wantSegments: []string{}}, {name: "empty", raw: "", openID: "ou_f", wantName: "ou_f", wantSegments: []string{}}, {name: "fallback line", raw: "\n\n真名", openID: "ou_g", wantName: "真名", wantSegments: []string{}}, // A blank first line must not make the description echo the name back and @@ -352,7 +357,7 @@ func TestProjectBotsMapsEveryField(t *testing.T) { data := &botSearchAPIData{Items: []botSearchAPIItem{ { ID: "ou_with_chat", - DisplayInfo: "会议助手\n提醒助手", + DisplayInfo: "甲乙丙\n一句话简介", MetaData: botSearchAPIMeta{ TenantID: "1", EnableJoinGroup: true, ChatID: "oc_p2p", IsAgent: true, }, @@ -369,9 +374,9 @@ func TestProjectBotsMapsEveryField(t *testing.T) { t.Fatalf("bots: got %d, want 2", len(bots)) } first := bots[0] - if first.OpenID != "ou_with_chat" || first.Name != "会议助手" || first.Description != "提醒助手" || + if first.OpenID != "ou_with_chat" || first.Name != "甲乙丙" || first.Description != "一句话简介" || first.P2PChatID != "oc_p2p" || !first.HasChatted || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" || - fmt.Sprint(first.MatchSegments) != "[会议助手]" { + fmt.Sprint(first.MatchSegments) != "[甲乙丙]" { t.Fatalf("first bot mapping: %+v", first) } second := bots[1] @@ -417,7 +422,7 @@ func botSearchStub(url string, pageToken string) *httpmock.Stub { "items": []interface{}{ map[string]interface{}{ "id": "ou_bot", - "display_info": "会议助手\n推送未接会议消息提醒", + "display_info": "甲乙丙\n一句话简介", "meta_data": map[string]interface{}{ "tenant_id": "1", "enable_join_group": true, "chat_id": "oc_p2p", "is_agent": false, }, @@ -434,7 +439,7 @@ func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) { registry.Register(stub) err := mountAndRun(t, ContactSearchBot, []string{ - "+search-bot", "--query", "助手", "--chat-ids", "oc_a,oc_b", "--has-chatted", + "+search-bot", "--query", "甲乙", "--chat-ids", "oc_a,oc_b", "--has-chatted", "--page-size", "25", "--format", "json", "--as", "user", }, factory, stdout) if err != nil { @@ -445,7 +450,7 @@ func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) { if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil { t.Fatalf("request body: %v", err) } - if requestBody["query"] != "助手" { + if requestBody["query"] != "甲乙" { t.Fatalf("request query: got %v", requestBody["query"]) } filter, ok := requestBody["filter"].(map[string]interface{}) @@ -474,7 +479,7 @@ func TestBotSearchIntegrationNeverSurfacesPageToken(t *testing.T) { // +search-user, which decodes page_token and drops it. registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out")) - err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "助手", "--format", "json", "--as", "user"}, factory, stdout) + err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "json", "--as", "user"}, factory, stdout) if err != nil { t.Fatalf("execute: %v", err) } @@ -492,7 +497,7 @@ func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) { factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out")) - err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "助手", "--format", "pretty", "--as", "user"}, factory, stdout) + err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "pretty", "--as", "user"}, factory, stdout) if err != nil { t.Fatalf("execute: %v", err) } @@ -522,7 +527,7 @@ func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) { factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out")) - err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "助手", "--format", "table", "--as", "user"}, factory, stdout) + err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "table", "--as", "user"}, factory, stdout) if err != nil { t.Fatalf("execute: %v", err) } @@ -549,7 +554,7 @@ func TestBotSearchCSVAndNDJSONExposeFullFieldsWithoutPaginationHint(t *testing.T factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out")) - err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "助手", "--format", format, "--as", "user"}, factory, stdout) + err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", format, "--as", "user"}, factory, stdout) if err != nil { t.Fatalf("execute: %v", err) } @@ -593,7 +598,7 @@ func TestBotSearchPrettyEmptyResult(t *testing.T) { func TestBotSearchDryRunMirrorsRequest(t *testing.T) { factory, stdout, _, _ := cmdutil.TestFactory(t, botSearchDefaultConfig()) err := mountAndRun(t, ContactSearchBot, []string{ - "+search-bot", "--query", "助手", "--chat-ids", "oc_a", "--has-chatted", + "+search-bot", "--query", "甲乙", "--chat-ids", "oc_a", "--has-chatted", "--page-size", "25", "--dry-run", "--as", "user", }, factory, stdout) if err != nil { @@ -619,7 +624,7 @@ func TestBotSearchDryRunMirrorsRequest(t *testing.T) { if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) { t.Fatalf("dry-run call: %+v", call) } - if call.Body.Query != "助手" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter { + if call.Body.Query != "甲乙" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter { t.Fatalf("dry-run body: %+v", call.Body) } } @@ -645,7 +650,7 @@ func TestBotSearchNoticeReachesCallerInEveryFormat(t *testing.T) { factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) registry.Register(botSearchStub(botSearchURL+"?page_size=20", "")) if err := mountAndRun(t, ContactSearchBot, []string{ - "+search-bot", "--query", "助手", "--format", format, "--as", "user", + "+search-bot", "--query", "甲乙", "--format", format, "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute: %v", err) } From 2824847a20a7a35d737d0399371146398dfefc83 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 16:41:29 +0800 Subject: [PATCH 19/27] docs(contact): route a bare name to bot search when the user search comes up empty A name on its own rarely says whether it belongs to a person or a bot. "Put reviewDuck in the group" reads like a bot but could be a colleague's nickname, and the routing evaluation for +search-bot showed this is the actual failure mode: with no hint in the docs, the agent's first move for a bot name was +search-user every time, and it stopped at "no such user" rather than trying the other command. Adds a short subsection telling the reader to search both sides -- fall back to +search-bot when +search-user finds nothing, or lead with it when the name looks like tooling -- and to treat "empty on both" as the only real miss. Also repeats the one trap a caller hits right after finding the bot: adding it to a chat needs the app's cli_ app_id, which +search-bot does not return. Purely additive; no existing line in the file changed. --- skills/lark-contact/SKILL.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index b6c516e773..39a105cfa7 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -22,6 +22,16 @@ metadata: 已知 open_id 只是想发消息 / 排日程,不必经过 contact —— 直接 [`lark-im`](../lark-im/SKILL.md) / [`lark-calendar`](../lark-calendar/SKILL.md)。 +### 名字没说清是人还是机器人 + +用户给的名字常常不表明类型。「把 reviewDuck 拉进群」里的 reviewDuck 很可能是机器人,但也可能是同事昵称 —— **不要猜,搜两边**: + +- `+search-user` 搜不到时,**退化到 `+search-bot` 再搜一次**,不要直接回「找不到这个人」 +- 名字有明显的工具色彩(英文驼峰、含 bot / 助手 / 机器人 / assistant 等)时,反过来先搜机器人更快 +- 两边都空才是真没有 + +注意机器人**拉不进群**:入群需要应用的 `cli_` app_id,`+search-bot` 只给 `ou_` open_id,细节见 [`lark-contact-search-bot.md`](references/lark-contact-search-bot.md)。 + ## 典型场景 找张三给他发消息:先搜,确认 open_id,再发: From b0ffe8d7b6ae2a1bbddb4deaa2b5a5e1ded6d9a4 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 17:00:52 +0800 Subject: [PATCH 20/27] fix(contact): signal truncation on stderr for every format without an envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last round fixed notice for the four formats that dropped it but left has_more behind, so csv and ndjson still carried neither: no field in stdout, no line on stderr. A machine caller piping either one read a truncated result as the whole answer. A test named ...WithoutPaginationHint was asserting exactly that, which is how the gap survived — it pinned the bug instead of catching it. The hint gate moves from isHumanReadableFormat to "stdout has no envelope", so csv and ndjson now get it alongside pretty and table while json keeps it in the envelope and gets no duplicate. Fanout adds a per-keyword has_more line for the same reason: ndjson gets neither the summary nor queries[]. The helper is renamed botSearchStdoutCarriesEnvelope since it now governs notice, has_more and the fanout sidecar rather than notice alone. Verified live at --page-size 1: csv and ndjson report on stderr, json reports in the envelope with stderr clean. The renamed test now requires the signal instead of forbidding it, and a new case covers all five formats. Also strengthens the meta_data.chat_id note. It asserted the official docs are wrong on the strength of an inference; the field is now backed by two checks a reader in another tenant can repeat — GET im/v1/chats/ returns chat_mode=p2p, and a bot surfaced through --chat-ids has an empty meta_data.chat_id rather than the group it was found in — and the note asks for a correction if another tenant behaves differently. Docs also warn that piping a non-json format without reading stderr hides truncation, which is the practical consequence of keeping stdout data-only. --- shortcuts/contact/contact_search_bot.go | 20 ++++---- .../contact/contact_search_bot_fanout.go | 5 +- shortcuts/contact/contact_search_bot_test.go | 48 ++++++++++++++++--- .../references/lark-contact-search-bot.md | 22 +++++---- 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index aa596b56ae..7ce623e10a 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -291,14 +291,14 @@ func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, e return req, nil } -// botSearchStdoutCarriesNotice reports whether the chosen format puts the -// server's notice into stdout. Only the json envelope does; pretty, table, csv -// and ndjson all render rows only, so a notice ("results are incomplete", "the -// query was truncated") would vanish and the caller would read a partial result -// as a complete one. For those formats the notice goes to stderr, which keeps -// stdout pipe-clean. A --jq expression can still project the notice away, but -// that is the caller's explicit choice. -func botSearchStdoutCarriesNotice(format string) bool { +// botSearchStdoutCarriesEnvelope reports whether the chosen format puts the +// response envelope — notice, has_more, and in fanout mode queries[] — into +// stdout. Only json does; pretty, table, csv and ndjson render rows only, so +// every piece of "this result is not the whole answer" metadata would vanish and +// the caller would read a truncated result as a complete one. For those formats +// the metadata goes to stderr, which keeps stdout pipe-clean. A --jq expression +// can still project it away, but that is the caller's explicit choice. +func botSearchStdoutCarriesEnvelope(format string) bool { return format == "json" || format == "" } @@ -340,10 +340,10 @@ func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) } output.PrintTable(w, prettyBotRows(bots)) }) - if respData.Notice != "" && !botSearchStdoutCarriesNotice(runtime.Format) { + if respData.Notice != "" && !botSearchStdoutCarriesEnvelope(runtime.Format) { fmt.Fprintf(runtime.IO().ErrOut, "\nnotice: %s\n", respData.Notice) } - if respData.HasMore && isHumanReadableFormat(runtime.Format) { + if respData.HasMore && !botSearchStdoutCarriesEnvelope(runtime.Format) { fmt.Fprintln(runtime.IO().ErrOut, "\nhint: more matches exist; narrow with --has-chatted or a more specific --query") } diff --git a/shortcuts/contact/contact_search_bot_fanout.go b/shortcuts/contact/contact_search_bot_fanout.go index 2d71f68370..a9220ec375 100644 --- a/shortcuts/contact/contact_search_bot_fanout.go +++ b/shortcuts/contact/contact_search_bot_fanout.go @@ -202,7 +202,7 @@ func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) // json envelope carries queries[].error / queries[].notice. Without this an // agent reading csv or a table sees "1 failed" with no way to learn the // keyword or the reason, and a notice disappears entirely. - if !botSearchStdoutCarriesNotice(runtime.Format) { + if !botSearchStdoutCarriesEnvelope(runtime.Format) { for _, qs := range resp.Queries { if qs.Error != "" { fmt.Fprintf(runtime.IO().ErrOut, "failed: %q — %s\n", qs.Query, qs.Error) @@ -210,6 +210,9 @@ func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) if qs.Notice != "" { fmt.Fprintf(runtime.IO().ErrOut, "notice: %q — %s\n", qs.Query, qs.Notice) } + if qs.HasMore { + fmt.Fprintf(runtime.IO().ErrOut, "has_more: %q — more matches exist; narrow this keyword\n", qs.Query) + } } } return nil diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 94cc454cfe..4623ae12c2 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -548,7 +548,12 @@ func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) { } } -func TestBotSearchCSVAndNDJSONExposeFullFieldsWithoutPaginationHint(t *testing.T) { +// The old name and assertion here pinned a bug: csv and ndjson were the two +// formats that carried neither has_more in stdout nor a hint on stderr, so a +// machine caller read a truncated result as the whole answer. stdout stays +// data-only; the truncation signal belongs on stderr for every format whose +// stdout has no envelope. +func TestBotSearchCSVAndNDJSONCarryFullFieldsAndSignalTruncation(t *testing.T) { for _, format := range []string{"csv", "ndjson"} { t.Run(format, func(t *testing.T) { factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) @@ -563,13 +568,15 @@ func TestBotSearchCSVAndNDJSONExposeFullFieldsWithoutPaginationHint(t *testing.T t.Errorf("%s output missing %q: %s", format, field, stdout.String()) } } - // Machine formats get no pagination hint, but the notice still has to - // reach the caller somewhere, and stdout must stay data-only. - if strings.Contains(stderr.String(), "hint: more matches exist") { - t.Fatalf("%s must not emit the pagination hint: %q", format, stderr.String()) + // stdout must stay data-only, so both the notice and the truncation + // signal have to arrive on stderr. + for _, want := range []string{"notice: The query is too long", "hint: more matches exist"} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("%s dropped %q from stderr: %q", format, want, stderr.String()) + } } - if !strings.Contains(stderr.String(), "notice: The query is too long") { - t.Fatalf("%s dropped the notice: %q", format, stderr.String()) + if strings.Contains(stdout.String(), "more matches exist") { + t.Fatalf("%s stdout must stay data-only: %s", format, stdout.String()) } }) } @@ -671,3 +678,30 @@ func TestBotSearchNoticeReachesCallerInEveryFormat(t *testing.T) { }) } } + +// has_more is the server saying "this is not the whole answer". Only the json +// envelope carries it, so every other format has to say so on stderr or a machine +// caller silently treats a truncated result as complete. +func TestBotSearchTruncationReachesCallerInEveryFormat(t *testing.T) { + for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} { + t.Run(format, func(t *testing.T) { + factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor")) + if err := mountAndRun(t, ContactSearchBot, []string{ + "+search-bot", "--query", "甲乙", "--format", format, "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute: %v", err) + } + if format == "json" { + if !strings.Contains(stdout.String(), `"has_more": true`) { + t.Fatalf("json must carry has_more in the envelope: %s", stdout.String()) + } + return + } + if !strings.Contains(stderr.String(), "more matches exist") { + t.Fatalf("%s left the caller unable to learn the result was truncated\nstdout:\n%s\nstderr:\n%s", + format, stdout.String(), stderr.String()) + } + }) + } +} diff --git a/skills/lark-contact/references/lark-contact-search-bot.md b/skills/lark-contact/references/lark-contact-search-bot.md index 0389b84cda..04a9fb2d9f 100644 --- a/skills/lark-contact/references/lark-contact-search-bot.md +++ b/skills/lark-contact/references/lark-contact-search-bot.md @@ -108,12 +108,14 @@ lark-cli contact +search-bot --query '会议助手' \ 和 `+search-user` 一致:没有 `--page-token`,输出也不给 `page_token`。`has_more=true` 时用 `--has-chatted` 或更具体的关键词收窄,而不是翻页 —— 注意 `--chat-ids` 起不到收窄作用。 +`has_more` 只在 `json` 的信封里;其余格式会在 **stderr** 输出一行 `hint: more matches exist; ...`。所以用 `csv` / `ndjson` 做管道时不读 stderr,就无法察觉结果被截断。 + ## 注意事项 - **`enable_join_group=true` 不等于你能把它拉进群。** 加机器人进群需要应用的 `cli_` 开头 app_id,本命令不返回;拿这里的 `ou_` open_id 去调加群接口,服务端会把它放进 `invalid_id_list`,而且没有 open_id → app_id 的查询接口。看到这个字段为真时不要声称已加入成功。 -- **`meta_data.chat_id` 的官方文档写错了。** 文档说是"机器人所属的群聊 ID",实际是**调用者与该机器人的单聊会话**。本命令按后者投影成 `p2p_chat_id`。 +- **`meta_data.chat_id` 与官方文档的说法不符。** 文档写的是"机器人所属的群聊 ID",实测是**调用者与该机器人的单聊会话**,所以本命令投影成 `p2p_chat_id`。两条可自行复核的依据:把返回的 chat_id 传给 `lark-cli api GET /open-apis/im/v1/chats/`,`chat_mode` 是 `p2p`;以及通过 `--chat-ids` 指定某群搜到的机器人,其 `meta_data.chat_id` 是**空的**,并不等于那个群。若你在别的租户观察到相反结果,以实测为准并回来更正这一条。 - **ID 类型只出 `open_id`。** 接口本身支持 `user_id_type`(实测 `union_id` 会返回 `on_...`),但本命令有固定输出结构,字段名会随之说谎,所以不暴露该参数;确实要 union_id / user_id 时走 `lark-cli api` 直调。 -- **`notice` 一定会到达调用方,但位置随格式变。** 服务端用它说明本次搜索的额外情况(如结果不全、query 被截断)。`json` 放在信封的 `data.notice`;其余格式的 stdout 只有数据行,所以 notice 写到 **stderr**(`notice: ...`),保证 stdout 仍可直接进管道。扇出模式下逐词的 notice 和失败原因同样会逐行写 stderr(`notice: "词" — ...` / `failed: "词" — ...`)。 +- **`notice` 和 `has_more` 一定会到达调用方,但位置随格式变。** 服务端用它说明本次搜索的额外情况(如结果不全、query 被截断)。`json` 放在信封里(`data.notice` / `data.has_more`);其余格式的 stdout 只有数据行,所以这两样都写到 **stderr**,保证 stdout 仍可直接进管道。扇出模式下逐词的 notice、失败原因和 has_more 同样逐行写 stderr(`notice: "词" — ...` / `failed: "词" — ...` / `has_more: "词" — ...`)。 ## 输出字段 contract @@ -156,12 +158,12 @@ lark-cli contact +search-bot --query '会议助手' \ ### 各 `--format` 的差异 -| format | stdout | notice / 逐词失败 | 分页提示 | 扇出计数 | -|---|---|---|---|---| -| `json` | 完整信封,含 `notice` 与 `queries[]` | 在 stdout 信封里 | 无 | 无 | -| `ndjson` | 每行一条记录,无信封 | 写 stderr | 无 | 无 | -| `csv` | 完整结果字段 | 写 stderr | 无 | 写 stderr | -| `table` | 完整结果字段 | 写 stderr | 写 stderr | 写 stderr | -| `pretty` | 摘要表:单关键词 6 列;`--queries` 模式多一列 `matched_query`,共 7 列 | 写 stderr | 写 stderr | 写 stderr | +| format | stdout | notice / has_more / 逐词失败 | 扇出计数 | +|---|---|---|---| +| `json` | 完整信封,含 `notice`、`has_more`、`queries[]` | 在 stdout 信封里 | 无 | +| `ndjson` | 每行一条记录,无信封 | 写 stderr | 无 | +| `csv` | 完整结果字段 | 写 stderr | 写 stderr | +| `table` | 完整结果字段 | 写 stderr | 写 stderr | +| `pretty` | 摘要表:单关键词 6 列;`--queries` 模式多一列 `matched_query`,共 7 列 | 写 stderr | 写 stderr | -**stdout 在任何格式下都只有数据**,提示类信息一律走 stderr,所以管道安全。扇出计数那行形如 `2 queries, 3 total bots; 1 failed, 0 with has_more`。 +**stdout 在任何格式下都只有数据**,"结果不完整"这类元信息一律走 stderr,所以管道安全 —— 但也意味着**用非 json 格式做管道时必须同时读 stderr**,否则会把截断结果当完整结果。扇出计数那行形如 `2 queries, 3 total bots; 1 failed, 0 with has_more`。 From ef2bf6eb5bf13ae8fc8844cd52b32cebed8a15c3 Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 18:06:49 +0800 Subject: [PATCH 21/27] docs(contact): clarify usage of --chat-ids and keyword requirements in search-bot documentation --- skills/lark-contact/SKILL.md | 15 ++++----------- .../references/lark-contact-search-bot.md | 9 +++++---- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index 39a105cfa7..30ded96a76 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-contact version: 1.0.0 -description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息,或需要查找机器人 open_id 时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" +description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人。当用户提到某人姓名要下一步发消息 / 排日程。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" metadata: requires: bins: ["lark-cli"] @@ -24,13 +24,9 @@ metadata: ### 名字没说清是人还是机器人 -用户给的名字常常不表明类型。「把 reviewDuck 拉进群」里的 reviewDuck 很可能是机器人,但也可能是同事昵称 —— **不要猜,搜两边**: - -- `+search-user` 搜不到时,**退化到 `+search-bot` 再搜一次**,不要直接回「找不到这个人」 +用户给的名字常常不表明类型。「把 reviewDuck 拉进群」里的 reviewDuck 很可能是机器人,但也可能是同事昵称。 - 名字有明显的工具色彩(英文驼峰、含 bot / 助手 / 机器人 / assistant 等)时,反过来先搜机器人更快 -- 两边都空才是真没有 - -注意机器人**拉不进群**:入群需要应用的 `cli_` app_id,`+search-bot` 只给 `ou_` open_id,细节见 [`lark-contact-search-bot.md`](references/lark-contact-search-bot.md)。 +- 不确定的话两边都搜一下 ## 典型场景 @@ -55,21 +51,18 @@ lark-cli contact user_profiles batch_query \ ## 搜索机器人 -`+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人,返回 `ou_` 开头的机器人 open_id。参数细节、输入归一化规则和输出结构见 [`lark-contact-search-bot.md`](references/lark-contact-search-bot.md)。 +`+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人,返回 `ou_` 开头的机器人 open_id。参数细节等见 [`lark-contact-search-bot.md`](references/lark-contact-search-bot.md)。 ```bash lark-cli contact +search-bot --query '会议助手' --as user lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user ``` -`enable_join_group=true` 只表示该机器人允许被拉进群聊,**不代表你能用这里的 `open_id` 把它拉进群**。把机器人加入群聊需要应用的 `cli_` 开头 app_id,本命令不返回; - ## 注意事项 - **41050 / Permission denied** 受当前身份的可见范围限制(两条命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。 - **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。 - **ID 类型**:默认 `open_id`。`+get-user` 可改 `--user-id-type union_id|user_id`;`+search-user`和 `+search-bot` 只接受 `open_id`。 -- **`+search-bot` 的权限**:只支持 user 身份,缺权限时重新授权 `search:bot`。 ## 不在本 skill 范围 diff --git a/skills/lark-contact/references/lark-contact-search-bot.md b/skills/lark-contact/references/lark-contact-search-bot.md index 04a9fb2d9f..4a800ea2fb 100644 --- a/skills/lark-contact/references/lark-contact-search-bot.md +++ b/skills/lark-contact/references/lark-contact-search-bot.md @@ -6,14 +6,15 @@ - ✅ 已知机器人名字想找出它的 open_id - ✅ 一次解析多个机器人名字(`--queries`,去重后最多 20 个词) -- ✅ 想知道某个群里有哪些机器人(`--chat-ids`,这是唯一能找到群内机器人的办法,见下) +- ✅ 在某个群的范围内按关键词找机器人(`--chat-ids`;它换的是搜索范围而不是过滤,见下) +- ❌ 想列出某个群的全部机器人 → 走群成员列表([`lark-im`](../../lark-im/SKILL.md)),本命令必须带关键词 - ✅ 想知道自己和哪些机器人聊过天 —— 但要配关键词 - ❌ 不给关键词、只想列出全部可见机器人 → 接口不支持,见下文 - ❌ 已知 open_id 想给机器人发消息 → 直接走 `lark-im`,不经过本命令 ## 关键 flag -**必须给关键词**:`--query` 或 `--queries` 至少一个。两个 filter 都不能独立枚举 —— 服务端对纯 filter 请求返回**空列表而不是报错**,所以 CLI 提前拦住,避免"没有这种机器人"的假结论。 +**必须给关键词**:`--query` 或 `--queries` 至少一个。`--chat-ids` 和 `--has-chatted` 只能配合关键词使用,不能独立枚举。 | Flag | 作用 | |---|---| @@ -25,7 +26,7 @@ ### `--chat-ids` 是换范围,不是过滤 -**群内的机器人在租户级搜索里可能完全不可见,只有指定群才搜得到。** 所以 `--chat-ids` 的结果不是无过滤结果的子集,它能让你找到 `--query` 单独怎么都搜不出来的机器人。实测过的两种情形: +**群内的机器人在租户级搜索里可能完全不可见,本命令下只有指定群才搜得到。** 所以 `--chat-ids` 的结果不是无过滤结果的子集,它能让你找到 `--query` 单独怎么都搜不出来的机器人。实测过的两种情形: - 某个关键词在租户级搜索返回 **0 条**,把该机器人所在的群传给 `--chat-ids` 后就能搜到 - 同一个关键词,加 `--chat-ids` 前后返回的是**两组互不相交的结果**,而不是子集 @@ -49,7 +50,7 @@ # 按名字找,拿 open_id lark-cli contact +search-bot --query '会议助手' --as user -# 找某个群里的机器人(租户级搜不到它时的唯一办法) +# 在某个群的范围内找(租户级搜不到它时用这个) lark-cli contact +search-bot --query '助' --chat-ids oc_xxx --as user # 只要聊过天的 From 9a58aef9c02b3f453eda10b7d054e983abb4eb5c Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 18:21:32 +0800 Subject: [PATCH 22/27] docs(contact): update search-bot documentation for keyword and flag usage --- .../references/lark-contact-search-bot.md | 171 ++++-------------- 1 file changed, 39 insertions(+), 132 deletions(-) diff --git a/skills/lark-contact/references/lark-contact-search-bot.md b/skills/lark-contact/references/lark-contact-search-bot.md index 4a800ea2fb..3fd1982ffe 100644 --- a/skills/lark-contact/references/lark-contact-search-bot.md +++ b/skills/lark-contact/references/lark-contact-search-bot.md @@ -1,170 +1,77 @@ # +search-bot -仅支持 user 身份,需要 `search:bot` 权限。 - -## 适用范围 +按关键词搜索当前用户可见的机器人。仅支持 user 身份,需要 `search:bot` 权限。 - ✅ 已知机器人名字想找出它的 open_id -- ✅ 一次解析多个机器人名字(`--queries`,去重后最多 20 个词) -- ✅ 在某个群的范围内按关键词找机器人(`--chat-ids`;它换的是搜索范围而不是过滤,见下) -- ❌ 想列出某个群的全部机器人 → 走群成员列表([`lark-im`](../../lark-im/SKILL.md)),本命令必须带关键词 -- ✅ 想知道自己和哪些机器人聊过天 —— 但要配关键词 -- ❌ 不给关键词、只想列出全部可见机器人 → 接口不支持,见下文 -- ❌ 已知 open_id 想给机器人发消息 → 直接走 `lark-im`,不经过本命令 +- ✅ 一次解析多个名字(`--queries`) +- ✅ 某个机器人在租户级搜不到、但知道它在哪个群(`--chat-ids`) -## 关键 flag +## flag -**必须给关键词**:`--query` 或 `--queries` 至少一个。`--chat-ids` 和 `--has-chatted` 只能配合关键词使用,不能独立枚举。 +**关键词必填**:`--query` 或 `--queries` 至少给一个。另外两个 flag 只能配合关键词,不能独立枚举。传错会得到 typed validation error,`param` / `params` 会点名该改哪个 flag。 -| Flag | 作用 | +| Flag | 说明 | |---|---| | `--query ` | 关键词,≤ 50 字符(按字符计,不是字节) | -| `--queries ` | 多个关键词并行搜,**去重后最多 20 个词**,每词 ≤ 50 字符;与 `--query` 互斥;输出 shape 不同(见下) | -| `--chat-ids ` | **改变搜索范围**到这些群内,不是收窄(见下);**最多 100 个去重后的 chat_id** | -| `--has-chatted` | 收窄到和自己有单聊会话的;显式传 `=false` 会报错 —— 不传等于不过滤 | +| `--queries ` | 多个关键词并行搜。先去空白、丢空项、精确去重(大小写敏感)、保留首次出现顺序,**再**按去重结果算上限:≤ 20 个词,每词 ≤ 50 字符。与 `--query` 互斥,输出结构也不同(见 fanout) | +| `--chat-ids ` | 改到**这些群内**去搜,而不是在租户范围里搜(见下)。接受裸 `oc_...` 或含 `oc_...` 的群链接,链接先归一化再去重,**≤ 100 个去重后的 chat_id** | +| `--has-chatted` | 只要和自己有单聊会话的。显式传 `=false` 会报错 —— 不传就等于不过滤 | | `--page-size ` | 每次返回条数,1–30(服务端上限就是 30) | -### `--chat-ids` 是换范围,不是过滤 - -**群内的机器人在租户级搜索里可能完全不可见,本命令下只有指定群才搜得到。** 所以 `--chat-ids` 的结果不是无过滤结果的子集,它能让你找到 `--query` 单独怎么都搜不出来的机器人。实测过的两种情形: - -- 某个关键词在租户级搜索返回 **0 条**,把该机器人所在的群传给 `--chat-ids` 后就能搜到 -- 同一个关键词,加 `--chat-ids` 前后返回的是**两组互不相交的结果**,而不是子集 - -对照 `--has-chatted` 才是真收窄:同一个关键词加上它之后结果条数下降,且剩下的 open_id 是原结果里的。 - -推论:**搜不到某个机器人时,先想想它是不是只存在于某个群里**,把群 ID 传进来再搜,而不是换关键词。 - -### 关键词匹配不是子串匹配 - -服务端的匹配规则未公开,黑盒实测能确定的是**它不是简单子串**:一个完整名字能匹配到中间多插了一个字的机器人,但反过来拿该名字的**后半段**去搜就匹配不到同一个机器人。所以搜不中时优先换更完整的名字,而不是截更短的片段。 - -### 输入归一化规则 - -- `--queries`:去首尾空白 → 丢弃空项 → 大小写敏感的精确去重 → 保留首次出现顺序。**20 个上限是按去重后的数量算的**,`'助手,助手,助手'` 只发一次请求,21 项里有 1 项重复也能通过。 -- `--chat-ids`:接受裸 `oc_...`,也接受包含 `oc_...` 的飞书 / Lark 群链接 —— 链接会先归一化成裸 chat_id,**再**按 chat_id 去重,**最后**才检查 100 上限。所以同一个群写成链接和裸 ID 各传一遍只算一个,101 个相同 ID 也能通过。 - -## 常用例子 - ```bash -# 按名字找,拿 open_id lark-cli contact +search-bot --query '会议助手' --as user - -# 在某个群的范围内找(租户级搜不到它时用这个) -lark-cli contact +search-bot --query '助' --chat-ids oc_xxx --as user - -# 只要聊过天的 lark-cli contact +search-bot --query '助手' --has-chatted --as user - -# 一次解析多个名字 lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user ``` -会被拒绝的写法(都是 typed validation error,`type: validation` / `subtype: invalid_argument`,退出码 2): - -| 写法 | 错误信封点名 | -|---|---| -| 只给 filter 不给关键词,如 `--has-chatted` 单独用 | `params: ["--query", "--queries"]` | -| `--query` 与 `--queries` 同传 | `params: ["--query", "--queries"]`,message 说 mutually exclusive | -| 去重后超过 20 个词 | `param: "--queries"`,`must be at most 20 entries (got N)` | -| `--queries ',,,'` 解析不出词 | `param: "--queries"`,`no valid query parsed from ",,,"` | -| `--query` 超过 50 字符 | `param: "--query"` | -| `--has-chatted=false` 显式传 | `param: "--has-chatted"` | -| `--chat-ids` 里有非 `oc_` 开头的值 | `param: "--chat-ids"` | - -## 批量并行查询 (fanout) - -```bash -lark-cli contact +search-bot --queries '会议助手,日报,审批' --as user -``` - -- 每行 bot 带 `matched_query`,标识来自哪个词 -- `queries[]` 每个去重后的词一条 `{query, error?, has_more, notice?}` -- 个别词失败不影响其他词,命令仍成功退出;**全部失败才报错**,且首个失败的分类(HTTP 状态 / API code)会透传 -- `--chat-ids` / `--has-chatted` 会作用到每一个词上 -- 并发上限 5;实测 20 个词约 4 秒 - -约束:去重后最多 20 个词、每词 ≤ 50 字符、全空 csv(`,,,`)报错。 - -## 多条命中怎么选 - -机器人重名比人更严重:一个业务领域的关键词往往命中十来个名字高度相似的机器人(同一个词根 + 不同修饰),光看名字分不出该用哪个。 +### 搜不到某个机器人?把它所在的群 ID 传进来 -后续操作若有副作用(拉群、发消息等),把候选列给用户挑,**不要擅自选第一条**。 - -筛选信号(可信度从高到低): - -1. `description` —— 机器人的一句话简介,最能区分功能。名字相近时几乎只能靠它 -2. `has_chatted` —— 你用过的那个,通常就是要找的 -3. `is_agent` —— 区分智能体和普通推送机器人 -4. `enable_join_group` —— 只在需要把它拉进群时才有筛选价值(注意它是空承诺,见下) +不带 `--chat-ids` 时,搜的范围是"当前用户在**整个租户**里可见的机器人"。有些机器人不在这个范围里,**只在某个群内可见**: ```bash -# 按简介精筛 -lark-cli contact +search-bot --query '会议助手' \ - --jq '.data.bots[] | select(.description | contains("<功能关键词>"))' --as user +lark-cli contact +search-bot --query '<机器人名>' --chat-ids oc_xxx --as user ``` -## 没有分页 - -和 `+search-user` 一致:没有 `--page-token`,输出也不给 `page_token`。`has_more=true` 时用 `--has-chatted` 或更具体的关键词收窄,而不是翻页 —— 注意 `--chat-ids` 起不到收窄作用。 +## 输出 -`has_more` 只在 `json` 的信封里;其余格式会在 **stderr** 输出一行 `hint: more matches exist; ...`。所以用 `csv` / `ndjson` 做管道时不读 stderr,就无法察觉结果被截断。 +`data.bots[]` 每个机器人一条,顶层还有 `has_more`(bool) 和 `notice`(string,可选)。 -## 注意事项 - -- **`enable_join_group=true` 不等于你能把它拉进群。** 加机器人进群需要应用的 `cli_` 开头 app_id,本命令不返回;拿这里的 `ou_` open_id 去调加群接口,服务端会把它放进 `invalid_id_list`,而且没有 open_id → app_id 的查询接口。看到这个字段为真时不要声称已加入成功。 -- **`meta_data.chat_id` 与官方文档的说法不符。** 文档写的是"机器人所属的群聊 ID",实测是**调用者与该机器人的单聊会话**,所以本命令投影成 `p2p_chat_id`。两条可自行复核的依据:把返回的 chat_id 传给 `lark-cli api GET /open-apis/im/v1/chats/`,`chat_mode` 是 `p2p`;以及通过 `--chat-ids` 指定某群搜到的机器人,其 `meta_data.chat_id` 是**空的**,并不等于那个群。若你在别的租户观察到相反结果,以实测为准并回来更正这一条。 -- **ID 类型只出 `open_id`。** 接口本身支持 `user_id_type`(实测 `union_id` 会返回 `on_...`),但本命令有固定输出结构,字段名会随之说谎,所以不暴露该参数;确实要 union_id / user_id 时走 `lark-cli api` 直调。 -- **`notice` 和 `has_more` 一定会到达调用方,但位置随格式变。** 服务端用它说明本次搜索的额外情况(如结果不全、query 被截断)。`json` 放在信封里(`data.notice` / `data.has_more`);其余格式的 stdout 只有数据行,所以这两样都写到 **stderr**,保证 stdout 仍可直接进管道。扇出模式下逐词的 notice、失败原因和 has_more 同样逐行写 stderr(`notice: "词" — ...` / `failed: "词" — ...` / `has_more: "词" — ...`)。 - -## 输出字段 contract - -`data.bots[]` 每个机器人一条: - -| 字段 | 类型 | 说明 | 空值行为 | +| 字段 | 类型 | 说明 | 空值时 | |---|---|---|---| -| `open_id` | string | `ou_` 开头的机器人 open_id,稳定标识 | 始终非空 | -| `name` | string | 从 `display_info` 第一行解析;解析不出时兜底为 `open_id` | 始终非空 | -| `description` | string (optional) | 从 `display_info` 第二行解析的一句话简介;**同名机器人主要靠它区分** | 空时**字段不出现** | -| `p2p_chat_id` | string | 与调用者的单聊会话(`oc_...`),可作为接受 `--chat-id` 的 IM 命令的输入 | 空时**字段仍在,值为空串** | +| `open_id` | string | `ou_` 开头,机器人的稳定标识 | 始终非空 | +| `name` | string | 从 `display_info` 第一行解析;解析不出兜底为 `open_id` | 始终非空 | +| `description` | string | 一句话简介(`display_info` 第二行)。**同名机器人主要靠它区分** | **整个字段消失** | +| `p2p_chat_id` | string | 与调用者的单聊会话,可喂给接受 `--chat-id` 的 IM 命令 | **字段仍在,值为空串** | | `has_chatted` | bool | `p2p_chat_id != ""` 的派生字段 | — | -| `enable_join_group` | bool | 是否允许被拉进群聊(但你拉不动,见上) | — | +| `enable_join_group` | bool | 是否允许被拉进群聊 | — | | `is_agent` | bool | 是否是智能体 | — | -| `tenant_id` | string (optional) | 租户标识 | 空时**字段不出现** | -| `match_segments` | string[] | 关键词命中的字符串片段,用于高亮展示 | 无命中时是 `[]`,不是 null | +| `tenant_id` | string | 租户标识 | **整个字段消失** | +| `match_segments` | string[] | 命中的关键词片段,供高亮 | 无命中是 `[]`,不是 null | -顶层:`has_more`(bool)、`notice`(string, optional,空时不出现)。 +三种空值语义不同,下游要分开处理:字段消失、空串、空数组。 -**三种空值语义各不相同**,下游要分别处理:`description` / `tenant_id` 会整个消失,`p2p_chat_id` 保留空串,`match_segments` 保留空数组。 +### 没有分页 -### `--queries` 模式额外字段 +和 `+search-user` 一致:没有 `--page-token`,也不返回 `page_token`。`has_more=true` 就收窄条件重搜,不是翻页。 -顶层 shape 变成 `{bots[], queries[], notice?}` —— **没有顶层 `has_more`**。 +### 多条命中怎么选 -`data.bots[]` 每条多一个字段: +一个业务领域的关键词往往命中十来个名字高度相似的机器人(同词根 + 不同修饰),光看名字分不出来。后续操作有副作用(拉群、发消息)时,把候选列给用户挑,**不要擅自选第一条**。 -| 字段 | 类型 | 说明 | -|---|---|---| -| `matched_query` | string | 这一行是被哪个关键词命中的 | +按可信度排序的筛选信号:`description`(最能区分功能,名字相近时几乎只能靠它)> `has_chatted`(你用过的那个)> `is_agent` > `enable_join_group`。 -`data.queries[]` 按去重后的输入顺序,每个词一条: +```bash +lark-cli contact +search-bot --query '会议助手' \ + --jq '.data.bots[] | select(.description | contains("<功能关键词>"))' --as user +``` -| 字段 | 类型 | 说明 | -|---|---|---| -| `query` | string | 关键词原文 | -| `error` | string (optional) | 该词失败的原因;成功时不出现 | -| `has_more` | bool | 该词是否还有更多命中 | -| `notice` | string (optional) | 该词的服务端补充说明 | +## fanout(`--queries`) -### 各 `--format` 的差异 +顶层变成 `{bots[], queries[], notice?}` —— **没有顶层 `has_more`**,它挪到逐词里。 -| format | stdout | notice / has_more / 逐词失败 | 扇出计数 | -|---|---|---|---| -| `json` | 完整信封,含 `notice`、`has_more`、`queries[]` | 在 stdout 信封里 | 无 | -| `ndjson` | 每行一条记录,无信封 | 写 stderr | 无 | -| `csv` | 完整结果字段 | 写 stderr | 写 stderr | -| `table` | 完整结果字段 | 写 stderr | 写 stderr | -| `pretty` | 摘要表:单关键词 6 列;`--queries` 模式多一列 `matched_query`,共 7 列 | 写 stderr | 写 stderr | +- `bots[]` 每条多一个 `matched_query`,标明来自哪个词 +- `queries[]` 按去重后的输入顺序,每词一条 `{query, error?, has_more, notice?}` +- 个别词失败**不影响其他词**,命令仍成功退出,原因在该词的 `error` 里;**全部失败才报错**,且首个失败的分类(HTTP 状态 / API code)会透传 +- `--chat-ids` / `--has-chatted` 作用到每一个词上 +- 并发上限 5 -**stdout 在任何格式下都只有数据**,"结果不完整"这类元信息一律走 stderr,所以管道安全 —— 但也意味着**用非 json 格式做管道时必须同时读 stderr**,否则会把截断结果当完整结果。扇出计数那行形如 `2 queries, 3 total bots; 1 failed, 0 with has_more`。 From 5d5a2f9300aca76f227bdc85f5fffc2e7ac3fa5c Mon Sep 17 00:00:00 2001 From: shanglei Date: Wed, 29 Jul 2026 18:24:17 +0800 Subject: [PATCH 23/27] docs(contact): update search-bot documentation for keyword and flag usage --- skills/lark-contact/references/lark-contact-search-bot.md | 1 - 1 file changed, 1 deletion(-) diff --git a/skills/lark-contact/references/lark-contact-search-bot.md b/skills/lark-contact/references/lark-contact-search-bot.md index 3fd1982ffe..3a5a2f254f 100644 --- a/skills/lark-contact/references/lark-contact-search-bot.md +++ b/skills/lark-contact/references/lark-contact-search-bot.md @@ -73,5 +73,4 @@ lark-cli contact +search-bot --query '会议助手' \ - `queries[]` 按去重后的输入顺序,每词一条 `{query, error?, has_more, notice?}` - 个别词失败**不影响其他词**,命令仍成功退出,原因在该词的 `error` 里;**全部失败才报错**,且首个失败的分类(HTTP 状态 / API code)会透传 - `--chat-ids` / `--has-chatted` 作用到每一个词上 -- 并发上限 5 From b6fa8d1989fd1da5de6bab3e822b597d8764d3be Mon Sep 17 00:00:00 2001 From: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:52:07 +0800 Subject: [PATCH 24/27] docs(contact): simplify bot search guidance --- affordance/contact.md | 10 ++- shortcuts/contact/contact_search_bot.go | 12 +--- skills/lark-contact/SKILL.md | 2 +- .../references/lark-contact-search-bot.md | 63 +++++++------------ 4 files changed, 35 insertions(+), 52 deletions(-) diff --git a/affordance/contact.md b/affordance/contact.md index ffba396acb..b96a092954 100644 --- a/affordance/contact.md +++ b/affordance/contact.md @@ -24,12 +24,15 @@ lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user ``` ## +search-bot -Find bots (apps) by keyword. Each match returns the bot's open_id plus p2p_chat_id / has_chatted so you can tell whether a conversation with it already exists. A keyword is mandatory — neither filter can list bots on its own. `--chat-ids` is not a narrowing filter: it searches inside those chats instead of the tenant-wide set, and surfaces bots a plain `--query` cannot find. +Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats. ### Avoid when - Looking for a person rather than a bot → use [[+search-user]] - Running as a bot — this shortcut is user-only +### Tips +- `has_more=true` means the search is incomplete; narrow the keyword or filters instead of paginating + ### Examples **Find a bot by name** @@ -42,6 +45,11 @@ lark-cli contact +search-bot --query "会议助手" --as user lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user ``` +**Find bots you've chatted with** +```bash +lark-cli contact +search-bot --query "助手" --has-chatted --as user +``` + **Resolve several bot names in one call** ```bash lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index 7ce623e10a..ae7f5d8f4a 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -91,21 +91,11 @@ var ContactSearchBot = common.Shortcut{ AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"}, - {Name: "chat-ids", Desc: "search inside these chats instead of the tenant-wide set; surfaces bots --query alone cannot find (CSV of chat_id; ≤ 100)"}, + {Name: "chat-ids", Desc: "search within specific chats (CSV of chat_id; ≤ 100)"}, {Name: "has-chatted", Type: "bool", Desc: "narrow a keyword search to bots you've chatted with (omit to disable; =false rejected)"}, {Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"}, {Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"}, }, - Tips: []string{ - "Keyword search: lark-cli contact +search-bot --query '会议助手' --as user", - "Search inside a chat: lark-cli contact +search-bot --query '助手' --chat-ids oc_xxx --as user", - "Narrow to bots you've chatted with: lark-cli contact +search-bot --query '助手' --has-chatted --as user", - "Multi-name fanout: lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user", - "A bot inside a chat can be invisible to a plain --query: pass --chat-ids to search that chat instead. Verified: a keyword returning nothing tenant-wide returned the chat's bot once the chat was named.", - "a keyword is required — pass --query or --queries; neither --chat-ids nor --has-chatted can enumerate on its own, and a filter-only request comes back empty rather than as an error.", - "on has_more=true narrow the search with --has-chatted or a more specific --query — there is no pagination.", - "enable_join_group=true only means the bot is allowed into chats. Adding it needs the app's cli_ app_id, which this command does not return: the ou_ open_id here is rejected by the chat-member APIs and there is no open_id → app_id lookup. Do not claim a bot was added on the strength of this flag.", - }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateBotSearch(runtime) }, diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index 30ded96a76..f4a5317a4a 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -62,7 +62,7 @@ lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' - **41050 / Permission denied** 受当前身份的可见范围限制(两条命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。 - **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。 -- **ID 类型**:默认 `open_id`。`+get-user` 可改 `--user-id-type union_id|user_id`;`+search-user`和 `+search-bot` 只接受 `open_id`。 +- **ID 类型**:`+get-user` 可通过 `--user-id-type` 使用 `open_id`、`union_id` 或 `user_id`;`+search-user` 使用用户 open_id;`+search-bot` 不支持按 ID 查询,它按关键词搜索并返回机器人 open_id。 ## 不在本 skill 范围 diff --git a/skills/lark-contact/references/lark-contact-search-bot.md b/skills/lark-contact/references/lark-contact-search-bot.md index 3a5a2f254f..65eb7afaec 100644 --- a/skills/lark-contact/references/lark-contact-search-bot.md +++ b/skills/lark-contact/references/lark-contact-search-bot.md @@ -4,19 +4,19 @@ - ✅ 已知机器人名字想找出它的 open_id - ✅ 一次解析多个名字(`--queries`) -- ✅ 某个机器人在租户级搜不到、但知道它在哪个群(`--chat-ids`) +- ✅ 在指定群范围内搜索机器人(`--chat-ids`) -## flag +## 参数 -**关键词必填**:`--query` 或 `--queries` 至少给一个。另外两个 flag 只能配合关键词,不能独立枚举。传错会得到 typed validation error,`param` / `params` 会点名该改哪个 flag。 +必须传 `--query` 或 `--queries`。`--chat-ids` 和 `--has-chatted` 只能用于筛选,不能单独使用。 | Flag | 说明 | |---|---| -| `--query ` | 关键词,≤ 50 字符(按字符计,不是字节) | -| `--queries ` | 多个关键词并行搜。先去空白、丢空项、精确去重(大小写敏感)、保留首次出现顺序,**再**按去重结果算上限:≤ 20 个词,每词 ≤ 50 字符。与 `--query` 互斥,输出结构也不同(见 fanout) | -| `--chat-ids ` | 改到**这些群内**去搜,而不是在租户范围里搜(见下)。接受裸 `oc_...` 或含 `oc_...` 的群链接,链接先归一化再去重,**≤ 100 个去重后的 chat_id** | -| `--has-chatted` | 只要和自己有单聊会话的。显式传 `=false` 会报错 —— 不传就等于不过滤 | -| `--page-size ` | 每次返回条数,1–30(服务端上限就是 30) | +| `--query ` | 搜索一个关键词,最多 50 个字符 | +| `--queries ` | 并行搜索多个关键词,最多 20 个;每个最多 50 个字符。不能和 `--query` 一起使用 | +| `--chat-ids ` | 只在指定群内搜索,最多 100 个群;支持群 ID 或群链接 | +| `--has-chatted` | 只返回聊过天的机器人;不需要时不要传此参数 | +| `--page-size ` | 返回条数,1–30,默认 20 | ```bash lark-cli contact +search-bot --query '会议助手' --as user @@ -24,53 +24,38 @@ lark-cli contact +search-bot --query '助手' --has-chatted --as user lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user ``` -### 搜不到某个机器人?把它所在的群 ID 传进来 - -不带 `--chat-ids` 时,搜的范围是"当前用户在**整个租户**里可见的机器人"。有些机器人不在这个范围里,**只在某个群内可见**: - -```bash -lark-cli contact +search-bot --query '<机器人名>' --chat-ids oc_xxx --as user -``` - ## 输出 -`data.bots[]` 每个机器人一条,顶层还有 `has_more`(bool) 和 `notice`(string,可选)。 - | 字段 | 类型 | 说明 | 空值时 | |---|---|---|---| -| `open_id` | string | `ou_` 开头,机器人的稳定标识 | 始终非空 | -| `name` | string | 从 `display_info` 第一行解析;解析不出兜底为 `open_id` | 始终非空 | -| `description` | string | 一句话简介(`display_info` 第二行)。**同名机器人主要靠它区分** | **整个字段消失** | -| `p2p_chat_id` | string | 与调用者的单聊会话,可喂给接受 `--chat-id` 的 IM 命令 | **字段仍在,值为空串** | -| `has_chatted` | bool | `p2p_chat_id != ""` 的派生字段 | — | -| `enable_join_group` | bool | 是否允许被拉进群聊 | — | +| `open_id` | string | 机器人 ID | 始终非空 | +| `name` | string | 机器人名称 | 无名称时使用 `open_id` | +| `description` | string | 机器人简介 | 字段省略 | +| `p2p_chat_id` | string | 与机器人的单聊 ID | 空字符串 | +| `has_chatted` | bool | 是否聊过天 | — | +| `enable_join_group` | bool | 是否允许加入群聊 | — | | `is_agent` | bool | 是否是智能体 | — | -| `tenant_id` | string | 租户标识 | **整个字段消失** | -| `match_segments` | string[] | 命中的关键词片段,供高亮 | 无命中是 `[]`,不是 null | - -三种空值语义不同,下游要分开处理:字段消失、空串、空数组。 +| `tenant_id` | string | 租户标识 | 字段省略 | +| `match_segments` | string[] | 命中的文本片段 | 无命中时为 `[]` | ### 没有分页 -和 `+search-user` 一致:没有 `--page-token`,也不返回 `page_token`。`has_more=true` 就收窄条件重搜,不是翻页。 +不支持分页。`has_more=true` 时应收窄关键词或搜索范围。 ### 多条命中怎么选 -一个业务领域的关键词往往命中十来个名字高度相似的机器人(同词根 + 不同修饰),光看名字分不出来。后续操作有副作用(拉群、发消息)时,把候选列给用户挑,**不要擅自选第一条**。 - -按可信度排序的筛选信号:`description`(最能区分功能,名字相近时几乎只能靠它)> `has_chatted`(你用过的那个)> `is_agent` > `enable_join_group`。 +命中多个机器人时,结合 `description`、`has_chatted` 和 `is_agent` 判断。后续要发消息或拉群时,让用户确认目标,不要直接选择第一条。 ```bash lark-cli contact +search-bot --query '会议助手' \ - --jq '.data.bots[] | select(.description | contains("<功能关键词>"))' --as user + --jq '.data.bots[] | select((.description // "") | contains("<功能关键词>"))' --as user ``` ## fanout(`--queries`) -顶层变成 `{bots[], queries[], notice?}` —— **没有顶层 `has_more`**,它挪到逐词里。 - -- `bots[]` 每条多一个 `matched_query`,标明来自哪个词 -- `queries[]` 按去重后的输入顺序,每词一条 `{query, error?, has_more, notice?}` -- 个别词失败**不影响其他词**,命令仍成功退出,原因在该词的 `error` 里;**全部失败才报错**,且首个失败的分类(HTTP 状态 / API code)会透传 -- `--chat-ids` / `--has-chatted` 作用到每一个词上 +输出为 `{bots[], queries[], notice?}`。`has_more` 只出现在每个关键词的结果中。 +- `bots[].matched_query`:该结果对应的关键词 +- `queries[]`:每个关键词的执行结果,格式为 `{query, error?, has_more, notice?}` +- 部分关键词失败时保留其他结果;全部失败时命令报错 +- `--chat-ids` 和 `--has-chatted` 对所有关键词生效 From 6612cae5bd5342743d856356767d9cd16068c82d Mon Sep 17 00:00:00 2001 From: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:21:22 +0800 Subject: [PATCH 25/27] fix(contact): harden bot search semantics --- affordance/contact.md | 6 +- shortcuts/contact/contact_search_bot.go | 25 ++---- .../contact/contact_search_bot_fanout.go | 82 +++++++++++++++---- .../contact/contact_search_bot_fanout_test.go | 82 ++++++++++++++----- shortcuts/contact/contact_search_bot_test.go | 53 ++++++------ skills/lark-contact/SKILL.md | 4 +- .../references/lark-contact-search-bot.md | 15 ++-- .../contact_search_bot_workflow_test.go | 4 +- tests/cli_e2e/contact/coverage.md | 2 +- 9 files changed, 180 insertions(+), 93 deletions(-) diff --git a/affordance/contact.md b/affordance/contact.md index b96a092954..cd3b2db996 100644 --- a/affordance/contact.md +++ b/affordance/contact.md @@ -31,11 +31,11 @@ Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` t - Running as a bot — this shortcut is user-only ### Tips -- `has_more=true` means the search is incomplete; narrow the keyword or filters instead of paginating +- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating ### Examples -**Find a bot by name** +**Find bots by keyword** ```bash lark-cli contact +search-bot --query "会议助手" --as user ``` @@ -50,7 +50,7 @@ lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as lark-cli contact +search-bot --query "助手" --has-chatted --as user ``` -**Resolve several bot names in one call** +**Search several bot keywords in one call** ```bash lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user ``` diff --git a/shortcuts/contact/contact_search_bot.go b/shortcuts/contact/contact_search_bot.go index ae7f5d8f4a..0d26b4f4a6 100644 --- a/shortcuts/contact/contact_search_bot.go +++ b/shortcuts/contact/contact_search_bot.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "html" "io" "net/http" "strconv" @@ -63,10 +64,8 @@ type searchBot struct { OpenID string `json:"open_id"` Name string `json:"name"` Description string `json:"description,omitempty"` - // No omitempty: searchUser.P2PChatID always emits, and a sibling command that - // silently drops the key would force callers to special-case bot results. - P2PChatID string `json:"p2p_chat_id"` - HasChatted bool `json:"has_chatted"` + // ChatID is the caller's P2P chat with the bot. + ChatID string `json:"chat_id"` EnableJoinGroup bool `json:"enable_join_group"` IsAgent bool `json:"is_agent"` TenantID string `json:"tenant_id,omitempty"` @@ -359,16 +358,12 @@ func projectBots(data *botSearchAPIData) []searchBot { bots := make([]searchBot, 0, len(data.Items)) for i := range data.Items { item := &data.Items[i] - name, description, segments := parseBotDisplayInfo(item.DisplayInfo, item.ID) - // Despite the API documentation, meta_data.chat_id is the caller's p2p - // chat with the bot, not a group that contains the bot. - p2pChatID := item.MetaData.ChatID + name, description, segments := parseBotDisplayInfo(item.DisplayInfo) bots = append(bots, searchBot{ OpenID: item.ID, Name: name, Description: description, - P2PChatID: p2pChatID, - HasChatted: p2pChatID != "", + ChatID: item.MetaData.ChatID, EnableJoinGroup: item.MetaData.EnableJoinGroup, IsAgent: item.MetaData.IsAgent, TenantID: item.MetaData.TenantID, @@ -378,17 +373,17 @@ func projectBots(data *botSearchAPIData) []searchBot { return bots } -func parseBotDisplayInfo(raw, openID string) (name, description string, matchSegments []string) { +func parseBotDisplayInfo(raw string) (name, description string, matchSegments []string) { matchSegments = make([]string, 0) for _, match := range displayInfoHighlightRE.FindAllStringSubmatch(raw, -1) { - matchSegments = append(matchSegments, match[1]) + matchSegments = append(matchSegments, html.UnescapeString(match[1])) } lines := strings.Split(raw, "\n") stripTags := func(value string) string { value = strings.ReplaceAll(value, "", "") value = strings.ReplaceAll(value, "", "") - return strings.TrimSpace(value) + return strings.TrimSpace(html.UnescapeString(value)) } // nameLine records which line the name came from, so the description is read @@ -411,9 +406,6 @@ func parseBotDisplayInfo(raw, openID string) (name, description string, matchSeg } } } - if name == "" { - name = openID - } if nameLine >= 0 && nameLine+1 < len(lines) { description = stripTags(lines[nameLine+1]) } @@ -427,7 +419,6 @@ func prettyBotRows(bots []searchBot) []map[string]interface{} { rows = append(rows, map[string]interface{}{ "name": bot.Name, "description": common.TruncateStr(bot.Description, 50), - "has_chatted": bot.HasChatted, "is_agent": bot.IsAgent, "enable_join_group": bot.EnableJoinGroup, "open_id": bot.OpenID, diff --git a/shortcuts/contact/contact_search_bot_fanout.go b/shortcuts/contact/contact_search_bot_fanout.go index a9220ec375..e50b6a4f4c 100644 --- a/shortcuts/contact/contact_search_bot_fanout.go +++ b/shortcuts/contact/contact_search_bot_fanout.go @@ -5,22 +5,21 @@ package contact import ( "context" + "errors" "fmt" "io" "net/http" "strconv" "sync" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) -// Bot fanout mirrors the user fanout in contact_search_user_fanout.go: same -// dedup, same concurrency cap, same per-query summary, same "fail only when every -// query fails" rule. It reuses parseAndDedupQueries, querySummary, -// isFanoutSummaryFormat and the contactFanout* error helpers rather than growing -// a second set. +// Bot fanout reuses the user fanout's query parsing, concurrency limit and +// response summary types. type botFanoutResult struct { Index int @@ -29,7 +28,7 @@ type botFanoutResult struct { HasMore bool Notice string ErrMsg string // empty = success - Err error // original failure, kept for typed all-failed propagation + Err error // original failure, kept for typed propagation } // runOneBotQuery converts one fanout request into either bots or an error summary. @@ -82,6 +81,48 @@ func botFanoutErrorResult(index int, query string, err error) botFanoutResult { return botFanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err} } +func botFanoutContextError(err error) error { + subtype := errs.SubtypeNetworkTransport + message := "bot search fanout cancelled" + if errors.Is(err, context.DeadlineExceeded) { + subtype = errs.SubtypeNetworkTimeout + message = "bot search fanout deadline exceeded" + } + return errs.NewNetworkError(subtype, "%s", message).WithCause(err) +} + +func botFanoutPanicError(query string, recovered any) error { + err := errs.NewInternalError(errs.SubtypeUnknown, + "bot search query %q panicked: %v", query, recovered) + if cause, ok := recovered.(error); ok { + return err.WithCause(cause) + } + return err +} + +// Terminal failures invalidate the batch; API and network failures remain +// eligible for partial-success reporting. +func botFanoutTerminalError(results []botFanoutResult) error { + for _, result := range results { + if result.Err == nil { + continue + } + if errors.Is(result.Err, context.Canceled) || errors.Is(result.Err, context.DeadlineExceeded) { + return botFanoutContextError(result.Err) + } + problem, ok := errs.ProblemOf(result.Err) + if !ok { + return errs.NewInternalError(errs.SubtypeUnknown, + "bot search query %q failed with an unclassified error: %v", result.Query, result.Err). + WithCause(result.Err) + } + if problem.Category != errs.CategoryAPI && problem.Category != errs.CategoryNetwork { + return result.Err + } + } + return nil +} + type fanoutBot struct { searchBot MatchedQuery string `json:"matched_query"` @@ -93,9 +134,13 @@ type botFanoutResponse struct { Notice string `json:"notice,omitempty"` } -// buildBotFanoutResponse flattens ordered fanout results and fails only when all -// queries fail. +// buildBotFanoutResponse flattens recoverable results in query order. Terminal +// errors fail the batch even when another query succeeded. func buildBotFanoutResponse(queries []string, results []botFanoutResult) (*botFanoutResponse, error) { + if err := botFanoutTerminalError(results); err != nil { + return nil, err + } + indexed := make([]botFanoutResult, len(queries)) for _, r := range results { indexed[r.Index] = r @@ -151,18 +196,28 @@ func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) var wg sync.WaitGroup sem := make(chan struct{}, fanoutConcurrency) +schedule: for i, q := range queries { + select { + case sem <- struct{}{}: + case <-ctx.Done(): + for j := i; j < len(queries); j++ { + results[j] = botFanoutErrorResult(j, queries[j], ctx.Err()) + } + break schedule + } wg.Add(1) - sem <- struct{}{} go func(i int, q string) { defer wg.Done() defer func() { <-sem }() defer func() { if r := recover(); r != nil { + err := botFanoutPanicError(q, r) results[i] = botFanoutResult{ Index: i, Query: q, - ErrMsg: fmt.Sprintf("internal error: %v", r), + ErrMsg: contactFanoutErrorSummary(err), + Err: err, } } }() @@ -195,7 +250,7 @@ func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) }) if isFanoutSummaryFormat(runtime.Format) { - fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total bots; %d failed, %d with has_more\n", + fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total matches; %d failed, %d with has_more\n", len(queries), len(resp.Bots), failed, hasMoreCount) } // The counts above say how many queries failed but not which, and only the @@ -218,9 +273,7 @@ func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) return nil } -// buildBotFanoutFilter reuses the single-search filter: --chat-ids and -// --has-chatted narrow every query in the fanout, exactly as the bool filters do -// for the user fanout. +// buildBotFanoutFilter applies the same search scope and filter to every query. func buildBotFanoutFilter(runtime *common.RuntimeContext) (*botSearchAPIFilter, error) { filter := &botSearchAPIFilter{} hasFilter := false @@ -251,7 +304,6 @@ func prettyBotFanoutRows(bots []fanoutBot) []map[string]interface{} { "matched_query": bot.MatchedQuery, "name": bot.Name, "description": common.TruncateStr(bot.Description, 50), - "has_chatted": bot.HasChatted, "is_agent": bot.IsAgent, "enable_join_group": bot.EnableJoinGroup, "open_id": bot.OpenID, diff --git a/shortcuts/contact/contact_search_bot_fanout_test.go b/shortcuts/contact/contact_search_bot_fanout_test.go index b3de8b79e4..3d10694fc2 100644 --- a/shortcuts/contact/contact_search_bot_fanout_test.go +++ b/shortcuts/contact/contact_search_bot_fanout_test.go @@ -6,6 +6,7 @@ package contact import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -109,6 +110,36 @@ func TestBotFanoutAssemblePartialFailureSucceeds(t *testing.T) { } } +func TestBotFanoutTerminalContextOverridesPartialSuccess(t *testing.T) { + tests := []struct { + name string + err error + wantSubtype errs.Subtype + }{ + {name: "cancelled", err: context.Canceled, wantSubtype: errs.SubtypeNetworkTransport}, + {name: "deadline", err: context.DeadlineExceeded, wantSubtype: errs.SubtypeNetworkTimeout}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := []botFanoutResult{ + {Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}}, + botFanoutErrorResult(1, "日报", tt.err), + } + _, err := buildBotFanoutResponse([]string{"会议", "日报"}, results) + if err == nil { + t.Fatal("terminal context error must fail the batch after a partial success") + } + if !errors.Is(err, tt.err) { + t.Fatalf("error must preserve %v as its cause: %v", tt.err, err) + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != tt.wantSubtype { + t.Fatalf("problem: got %+v, want network/%s", problem, tt.wantSubtype) + } + }) + } +} + func TestBotFanoutResponseHasNoTopLevelHasMore(t *testing.T) { resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议", HasMore: true}}) if err != nil { @@ -148,7 +179,7 @@ func TestBotFanoutEmptyBotsSerializesAsArray(t *testing.T) { func TestPrettyBotFanoutRowsLeadWithMatchedQuery(t *testing.T) { rows := prettyBotFanoutRows([]fanoutBot{{ - searchBot: searchBot{OpenID: "ou_a", Name: "会议助手", Description: strings.Repeat("长", 80), HasChatted: true}, + searchBot: searchBot{OpenID: "ou_a", Name: "会议助手", Description: strings.Repeat("长", 80)}, MatchedQuery: "会议", }}) if len(rows) != 1 { @@ -319,35 +350,35 @@ func TestBotFanoutConcurrencyCap(t *testing.T) { } } -func TestBotFanoutPanicIsContainedPerQuery(t *testing.T) { +func TestBotFanoutPanicFailsBatch(t *testing.T) { factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig()) + panicCause := errors.New("synthetic test panic") boom := botSearchStub(botSearchURL, "") boom.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"boom"`) } - boom.OnMatch = func(req *http.Request) { panic("synthetic test panic") } + boom.OnMatch = func(req *http.Request) { panic(panicCause) } registry.Register(boom) - ok := botSearchStub(botSearchURL, "") - ok.Reusable = true - registry.Register(ok) + okStub := botSearchStub(botSearchURL, "") + okStub.Reusable = true + registry.Register(okStub) err := mountAndRun(t, ContactSearchBot, []string{ "+search-bot", "--queries", "ok,boom,fine", "--format", "json", "--as", "user", }, factory, stdout) - if err != nil { - t.Fatalf("one panicking query must not bubble out of the batch; got %v", err) + if err == nil { + t.Fatal("a panicking query must fail the batch") } - - var got map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { - t.Fatalf("response JSON: %v\n%s", err, stdout.String()) + if !errors.Is(err, panicCause) { + t.Fatalf("panic cause must be preserved: %v", err) + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown { + t.Fatalf("problem: got %+v, want internal/%s", problem, errs.SubtypeUnknown) } - queries := got["data"].(map[string]interface{})["queries"].([]interface{}) - failed := queries[1].(map[string]interface{}) - if msg, _ := failed["error"].(string); !strings.HasPrefix(msg, "internal error:") { - t.Errorf("queries[1].error: want an 'internal error:' prefix, got %q", failed["error"]) + if stdout.Len() != 0 { + t.Fatalf("terminal failure must not write a success envelope: %s", stdout.String()) } - // A recovered panic must not dump a stack trace at the user. for _, marker := range []string{"goroutine ", ".go:", "runtime."} { if strings.Contains(stderr.String(), marker) { t.Errorf("stderr leaked stack-trace marker %q: %s", marker, stderr.String()) @@ -451,9 +482,12 @@ func TestBotFanoutCSVCarriesMatchedQueryAndSummary(t *testing.T) { t.Errorf("csv must expose matched_query so rows can be traced to a keyword: %s", stdout.String()) } // csv is in the summary format set, so the batch counters belong on stderr. - if !strings.Contains(stderr.String(), "2 queries") || !strings.Contains(stderr.String(), "0 failed") { + if !strings.Contains(stderr.String(), "2 queries, 2 total matches") || !strings.Contains(stderr.String(), "0 failed") { t.Errorf("stderr summary must report the batch counters: %s", stderr.String()) } + if strings.Contains(stderr.String(), "total bots") { + t.Errorf("summary must count matches rather than imply unique bots: %s", stderr.String()) + } } func TestBotFanoutNDJSONKeepsStdoutClean(t *testing.T) { @@ -496,11 +530,17 @@ func TestBotFanoutCancelledContextFailsEveryQuery(t *testing.T) { t.Fatalf("a cancelled context must short-circuit before the request: %+v", r) } } - // The pre-check exists so queued workers never issue a request after cancel; - // reaching DoAPI with a nil runtime would panic instead. - if _, err := buildBotFanoutResponse([]string{"会议", "日报"}, results); err == nil { + _, err := buildBotFanoutResponse([]string{"会议", "日报"}, results) + if err == nil { t.Fatal("all queries cancelled must surface as an error") } + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation cause must be preserved: %v", err) + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport { + t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport) + } } func TestBotFanoutDryRunPreviewsOneRequestPerKeyword(t *testing.T) { diff --git a/shortcuts/contact/contact_search_bot_test.go b/shortcuts/contact/contact_search_bot_test.go index 4623ae12c2..1515913e2c 100644 --- a/shortcuts/contact/contact_search_bot_test.go +++ b/shortcuts/contact/contact_search_bot_test.go @@ -314,32 +314,33 @@ func TestParseBotDisplayInfo(t *testing.T) { tests := []struct { name string raw string - openID string wantName string wantDescription string wantSegments []string }{ // Whole name highlighted, description on line two. - {name: "whole name highlighted", raw: "甲乙丙\n一句话简介", openID: "ou_a", wantName: "甲乙丙", wantDescription: "一句话简介", wantSegments: []string{"甲乙丙"}}, + {name: "whole name highlighted", raw: "甲乙丙\n一句话简介", wantName: "甲乙丙", wantDescription: "一句话简介", wantSegments: []string{"甲乙丙"}}, // Two highlighted runs split by a plain character: stripping tags has to // rejoin them into one name. - {name: "two highlighted runs", raw: "甲乙\n另一句简介", openID: "ou_b", wantName: "甲乙丁丙", wantDescription: "另一句简介", wantSegments: []string{"甲乙", "丙"}}, + {name: "two highlighted runs", raw: "甲乙\n另一句简介", wantName: "甲乙丁丙", wantDescription: "另一句简介", wantSegments: []string{"甲乙", "丙"}}, // Highlight at the end plus a trailing newline: line two exists but is empty. - {name: "trailing newline empty description", raw: "戊己的庚辛\n", openID: "ou_c", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}}, + {name: "trailing newline empty description", raw: "戊己的庚辛\n", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}}, // Single highlighted character in the middle of the name. - {name: "mid-name highlight", raw: "壬癸丑\n第二行简介", openID: "ou_d", wantName: "壬癸子丑", wantDescription: "第二行简介", wantSegments: []string{"子"}}, - {name: "no newline", raw: "寅卯", openID: "ou_e", wantName: "寅卯", wantSegments: []string{}}, - {name: "empty", raw: "", openID: "ou_f", wantName: "ou_f", wantSegments: []string{}}, - {name: "fallback line", raw: "\n\n真名", openID: "ou_g", wantName: "真名", wantSegments: []string{}}, + {name: "mid-name highlight", raw: "壬癸丑\n第二行简介", wantName: "壬癸子丑", wantDescription: "第二行简介", wantSegments: []string{"子"}}, + {name: "no newline", raw: "寅卯", wantName: "寅卯", wantSegments: []string{}}, + {name: "html entities", raw: "Lark部门成员&仓库\n来自飞书多维表格", wantName: "Lark部门成员&仓库", wantDescription: "来自飞书多维表格", wantSegments: []string{"Lark"}}, + {name: "html entity in highlight", raw: "名称&工具", wantName: "名称&工具", wantSegments: []string{"&"}}, + {name: "empty", raw: "", wantSegments: []string{}}, + {name: "first non-empty line", raw: "\n\n真名", wantName: "真名", wantSegments: []string{}}, // A blank first line must not make the description echo the name back and // swallow the real description on the line after it. - {name: "blank first line keeps description", raw: "\n真名\n简介", openID: "ou_h", wantName: "真名", wantDescription: "简介", wantSegments: []string{}}, - {name: "blank first line without description", raw: "\n真名", openID: "ou_i", wantName: "真名", wantSegments: []string{}}, + {name: "blank first line keeps description", raw: "\n真名\n简介", wantName: "真名", wantDescription: "简介", wantSegments: []string{}}, + {name: "blank first line without description", raw: "\n真名", wantName: "真名", wantSegments: []string{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - name, description, segments := parseBotDisplayInfo(tt.raw, tt.openID) + name, description, segments := parseBotDisplayInfo(tt.raw) if name != tt.wantName || description != tt.wantDescription { t.Fatalf("name/description: got %q/%q, want %q/%q", name, description, tt.wantName, tt.wantDescription) } @@ -364,7 +365,7 @@ func TestProjectBotsMapsEveryField(t *testing.T) { }, { ID: "ou_without_chat", - DisplayInfo: "无会话机器人", + DisplayInfo: "", MetaData: botSearchAPIMeta{TenantID: "1"}, }, }} @@ -375,22 +376,26 @@ func TestProjectBotsMapsEveryField(t *testing.T) { } first := bots[0] if first.OpenID != "ou_with_chat" || first.Name != "甲乙丙" || first.Description != "一句话简介" || - first.P2PChatID != "oc_p2p" || !first.HasChatted || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" || + first.ChatID != "oc_p2p" || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" || fmt.Sprint(first.MatchSegments) != "[甲乙丙]" { t.Fatalf("first bot mapping: %+v", first) } second := bots[1] - if second.P2PChatID != "" || second.HasChatted { - t.Fatalf("second bot chat fields: %+v", second) + if second.Name != "" || second.ChatID != "" { + t.Fatalf("empty source fields must stay empty: %+v", second) } raw, err := json.Marshal(searchBotResponse{Bots: bots}) if err != nil { t.Fatalf("marshal response: %v", err) } - // searchUser emits p2p_chat_id unconditionally; the sibling command must keep - // the same key set so callers need no bot-specific presence check. - if !strings.Contains(string(raw), `"p2p_chat_id":""`) { - t.Fatalf("empty p2p_chat_id must still be emitted: %s", raw) + if !strings.Contains(string(raw), `"chat_id":""`) { + t.Fatalf("empty chat_id must still be emitted: %s", raw) + } + if !strings.Contains(string(raw), `"name":""`) { + t.Fatalf("empty name must not fall back to open_id: %s", raw) + } + if strings.Contains(string(raw), `"has_chatted"`) { + t.Fatalf("chat_id presence must not be exposed as a has_chatted signal: %s", raw) } } @@ -467,7 +472,7 @@ func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) { if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || !envelope.Data.HasMore { t.Fatalf("response pass-through: %+v", envelope.Data) } - if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].P2PChatID != "oc_p2p" { + if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].ChatID != "oc_p2p" { t.Fatalf("bots: %+v", envelope.Data.Bots) } registry.Verify(t) @@ -501,12 +506,12 @@ func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) { if err != nil { t.Fatalf("execute: %v", err) } - for _, column := range []string{"name", "description", "has_chatted", "is_agent", "enable_join_group", "open_id"} { + for _, column := range []string{"name", "description", "is_agent", "enable_join_group", "open_id"} { if !strings.Contains(stdout.String(), column) { t.Errorf("pretty output missing %q: %s", column, stdout.String()) } } - for _, genericField := range []string{"bots", "has_more", "notice", "tenant_id", "p2p_chat_id", "match_segments"} { + for _, genericField := range []string{"bots", "has_more", "notice", "tenant_id", "chat_id", "match_segments"} { if strings.Contains(stdout.String(), genericField) { t.Errorf("pretty output exposed %q: %s", genericField, stdout.String()) } @@ -531,7 +536,7 @@ func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) { if err != nil { t.Fatalf("execute: %v", err) } - for _, field := range []string{"open_id", "tenant_id", "p2p_chat_id", "match_segments"} { + for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} { if !strings.Contains(stdout.String(), field) { t.Errorf("table output missing %q: %s", field, stdout.String()) } @@ -563,7 +568,7 @@ func TestBotSearchCSVAndNDJSONCarryFullFieldsAndSignalTruncation(t *testing.T) { if err != nil { t.Fatalf("execute: %v", err) } - for _, field := range []string{"open_id", "tenant_id", "p2p_chat_id", "match_segments"} { + for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} { if !strings.Contains(stdout.String(), field) { t.Errorf("%s output missing %q: %s", format, field, stdout.String()) } diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index f4a5317a4a..3085d7cb64 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-contact version: 1.0.0 -description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人。当用户提到某人姓名要下一步发消息 / 排日程。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" +description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" metadata: requires: bins: ["lark-cli"] @@ -15,7 +15,7 @@ metadata: | 想做什么 | user 身份 | bot 身份 | |---|---|---| | 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 | -| 按名称搜索当前用户可见的机器人 | [`+search-bot`](references/lark-contact-search-bot.md) | 不支持 | +| 按关键词搜索当前用户可见的机器人 | [`+search-bot`](references/lark-contact-search-bot.md) | 不支持 | | 已知 open_id 取他人资料 | `+search-user --user-ids ` | [`+get-user --user-id `](references/lark-contact-get-user.md) | | 查看自己 | `+get-user` 或 `+search-user --user-ids me` | 不支持 | | 查同事的个人状态 / 签名 | `user_profiles batch_query` | 不支持 | diff --git a/skills/lark-contact/references/lark-contact-search-bot.md b/skills/lark-contact/references/lark-contact-search-bot.md index 65eb7afaec..132d2d6f3a 100644 --- a/skills/lark-contact/references/lark-contact-search-bot.md +++ b/skills/lark-contact/references/lark-contact-search-bot.md @@ -2,13 +2,13 @@ 按关键词搜索当前用户可见的机器人。仅支持 user 身份,需要 `search:bot` 权限。 -- ✅ 已知机器人名字想找出它的 open_id -- ✅ 一次解析多个名字(`--queries`) +- ✅ 用关键词搜索机器人并获取 open_id +- ✅ 一次搜索多个关键词(`--queries`) - ✅ 在指定群范围内搜索机器人(`--chat-ids`) ## 参数 -必须传 `--query` 或 `--queries`。`--chat-ids` 和 `--has-chatted` 只能用于筛选,不能单独使用。 +必须传 `--query` 或 `--queries`。`--chat-ids` 指定搜索范围,`--has-chatted` 筛选已聊过的机器人;两者都不能单独使用。 | Flag | 说明 | |---|---| @@ -29,10 +29,9 @@ lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' | 字段 | 类型 | 说明 | 空值时 | |---|---|---|---| | `open_id` | string | 机器人 ID | 始终非空 | -| `name` | string | 机器人名称 | 无名称时使用 `open_id` | +| `name` | string | 机器人名称 | 空字符串 | | `description` | string | 机器人简介 | 字段省略 | -| `p2p_chat_id` | string | 与机器人的单聊 ID | 空字符串 | -| `has_chatted` | bool | 是否聊过天 | — | +| `chat_id` | string | 与机器人的单聊 ID | 空字符串 | | `enable_join_group` | bool | 是否允许加入群聊 | — | | `is_agent` | bool | 是否是智能体 | — | | `tenant_id` | string | 租户标识 | 字段省略 | @@ -40,11 +39,11 @@ lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' ### 没有分页 -不支持分页。`has_more=true` 时应收窄关键词或搜索范围。 +不支持分页。`has_more=true` 时改用更具体的关键词,或调整搜索范围。 ### 多条命中怎么选 -命中多个机器人时,结合 `description`、`has_chatted` 和 `is_agent` 判断。后续要发消息或拉群时,让用户确认目标,不要直接选择第一条。 +命中多个机器人时,结合 `description` 和 `is_agent` 判断。后续要发消息或拉群时,让用户确认目标,不要直接选择第一条。 ```bash lark-cli contact +search-bot --query '会议助手' \ diff --git a/tests/cli_e2e/contact/contact_search_bot_workflow_test.go b/tests/cli_e2e/contact/contact_search_bot_workflow_test.go index 705eee2cbc..9338f4cbdd 100644 --- a/tests/cli_e2e/contact/contact_search_bot_workflow_test.go +++ b/tests/cli_e2e/contact/contact_search_bot_workflow_test.go @@ -45,8 +45,8 @@ func TestContactSearchBotWorkflowAsUser(t *testing.T) { require.NotEmpty(t, openID, "every bot must carry open_id; stdout:\n%s", result.Stdout) require.True(t, strings.HasPrefix(openID, "ou_"), "bot ids are open_ids; stdout:\n%s", result.Stdout) - require.True(t, bot.Get("p2p_chat_id").Exists(), - "p2p_chat_id must be present even when empty; stdout:\n%s", result.Stdout) + require.True(t, bot.Get("chat_id").Exists(), + "chat_id must be present even when empty; stdout:\n%s", result.Stdout) require.True(t, bot.Get("match_segments").IsArray(), "match_segments must be an array, never null; stdout:\n%s", result.Stdout) } diff --git a/tests/cli_e2e/contact/coverage.md b/tests/cli_e2e/contact/coverage.md index d22d1d4508..64a630925f 100644 --- a/tests/cli_e2e/contact/coverage.md +++ b/tests/cli_e2e/contact/coverage.md @@ -8,7 +8,7 @@ ## Summary - TestContact_LookupWorkflowAsUser: proves the user lookup workflow through `get self as user` and `get self by open id as user`; reads the current user first and round-trips the returned `open_id` back into `+get-user`. - TestContact_LookupWorkflowAsBot: proves bot lookup through `discover user via api as bot` and `get user by open id as bot`; the raw API discovery step is fixture setup only and does not affect the domain denominator. -- TestContactSearchBotWorkflowAsUser: proves live bot search as user; validates the envelope shape (`bots[]` is an array, `has_more` present) and, for whatever rows the tenant returns, that `open_id` is an `ou_` id, `p2p_chat_id` is present even when empty, and `match_segments` is never null. Deliberately does not require a minimum row count: the assertions must hold in a tenant with no matching bot. +- TestContactSearchBotWorkflowAsUser: proves live bot search as user; validates the envelope shape (`bots[]` is an array, `has_more` present) and, for whatever rows the tenant returns, that `open_id` is an `ou_` id, the P2P `chat_id` is present even when empty, and `match_segments` is never null. Deliberately does not require a minimum row count: the assertions must hold in a tenant with no matching bot. - TestContactSearchBotRejectsFilterOnlyAsUser: pins that `--has-chatted` without a keyword is rejected as a typed validation error naming both `--query` and `--queries`. Rejected locally, so it needs no tenant data and issues no API call. - Blocked area: `contact +search-user` did not reliably return the current user in UAT even when queried with self-derived identifiers, so it remains uncovered rather than being counted from a flaky tenant-dependent assertion. From 7ae61464597a914e081422d1778797d149a1d80b Mon Sep 17 00:00:00 2001 From: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:39:13 +0800 Subject: [PATCH 26/27] docs(contact): clarify ambiguous-name example --- skills/lark-contact/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index 3085d7cb64..f07cd6976b 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -24,8 +24,8 @@ metadata: ### 名字没说清是人还是机器人 -用户给的名字常常不表明类型。「把 reviewDuck 拉进群」里的 reviewDuck 很可能是机器人,但也可能是同事昵称。 -- 名字有明显的工具色彩(英文驼峰、含 bot / 助手 / 机器人 / assistant 等)时,反过来先搜机器人更快 +用户给的名字常常不表明类型。例如「和 reviewDuck 约个会」里的 reviewDuck 可能是同事昵称,也可能是机器人。 +- 名字含 bot / 助手 / 机器人 / assistant 等明显特征时,反过来先搜机器人更快 - 不确定的话两边都搜一下 ## 典型场景 From ecc16472d4d716d7a549b2af002be58dd3a2c261 Mon Sep 17 00:00:00 2001 From: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:09:59 +0800 Subject: [PATCH 27/27] =?UTF-8?q?docs(contact):=20cover=20agent/=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=20wording=20for=20bot=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `+search-bot` returns both plain bots and agents (distinguished by the `is_agent` field), but every routing surface — the command table, both section headings, the name-hint list — said only 机器人. A request phrased around 智能体 or agent had no lexical path to the command. Also drop the person-type restriction in the skill description: 「某人 姓名」only covers human names, while the skill resolves bot and agent names through the same flow. Word swaps only, no new lines. --- skills/lark-contact/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index f07cd6976b..c5d54741d2 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-contact version: 1.0.0 -description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" +description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名,以及按关键词搜索当前用户可见的机器人 / 智能体(agent)。当用户提到一个名字要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。" metadata: requires: bins: ["lark-cli"] @@ -15,17 +15,17 @@ metadata: | 想做什么 | user 身份 | bot 身份 | |---|---|---| | 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 | -| 按关键词搜索当前用户可见的机器人 | [`+search-bot`](references/lark-contact-search-bot.md) | 不支持 | +| 按关键词搜索当前用户可见的机器人 / 智能体 | [`+search-bot`](references/lark-contact-search-bot.md) | 不支持 | | 已知 open_id 取他人资料 | `+search-user --user-ids ` | [`+get-user --user-id `](references/lark-contact-get-user.md) | | 查看自己 | `+get-user` 或 `+search-user --user-ids me` | 不支持 | | 查同事的个人状态 / 签名 | `user_profiles batch_query` | 不支持 | 已知 open_id 只是想发消息 / 排日程,不必经过 contact —— 直接 [`lark-im`](../lark-im/SKILL.md) / [`lark-calendar`](../lark-calendar/SKILL.md)。 -### 名字没说清是人还是机器人 +### 名字没说清是人还是机器人 / 智能体 用户给的名字常常不表明类型。例如「和 reviewDuck 约个会」里的 reviewDuck 可能是同事昵称,也可能是机器人。 -- 名字含 bot / 助手 / 机器人 / assistant 等明显特征时,反过来先搜机器人更快 +- 名字含 bot / agent / AI / 助手 / 机器人 / 智能体 / assistant 等明显特征时,反过来先搜机器人更快 - 不确定的话两边都搜一下 ## 典型场景 @@ -49,7 +49,7 @@ lark-cli contact user_profiles batch_query \ 搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。 -## 搜索机器人 +## 搜索机器人 / 智能体 `+search-bot` 使用 user 身份按关键词搜索当前用户可见的机器人,返回 `ou_` 开头的机器人 open_id。参数细节等见 [`lark-contact-search-bot.md`](references/lark-contact-search-bot.md)。