diff --git a/affordance/contact.md b/affordance/contact.md
index 13c1196837..cd3b2db996 100644
--- a/affordance/contact.md
+++ b/affordance/contact.md
@@ -23,6 +23,38 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
+## +search-bot
+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; refine the keyword or search scope instead of paginating
+
+### Examples
+
+**Find bots by keyword**
+```bash
+lark-cli contact +search-bot --query "会议助手" --as user
+```
+
+**Search inside one chat**
+```bash
+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
+```
+
+**Search several bot keywords 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
new file mode 100644
index 0000000000..0d26b4f4a6
--- /dev/null
+++ b/shortcuts/contact/contact_search_bot.go
@@ -0,0 +1,428 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package contact
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html"
+ "io"
+ "net/http"
+ "strconv"
+ "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"
+)
+
+const botSearchURL = "/open-apis/bot/v4/bot/search"
+
+const (
+ maxBotSearchQueryChars = 50
+ maxBotSearchChatIDs = 100
+ maxBotSearchPageSize = 30
+)
+
+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"`
+ // 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"`
+ 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"`
+ Notice string `json:"notice,omitempty"`
+}
+
+var ContactSearchBot = common.Shortcut{
+ Service: "contact",
+ Command: "+search-bot",
+ 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: "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"},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ 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())
+ }
+ return common.NewDryRunAPI().
+ POST(botSearchURL).
+ Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
+ Body(body)
+ },
+ 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)
+}
+
+// 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 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"},
+ )
+}
+
+// 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 != "" {
+ 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 == "" {
+ // 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")
+ }
+ return nil
+}
+
+func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) {
+ raw := strings.TrimSpace(runtime.Str("chat-ids"))
+ if raw == "" {
+ return nil, nil
+ }
+ 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")
+ }
+
+ // 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
+ }
+ 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
+}
+
+func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) {
+ req := &botSearchAPIRequest{Query: strings.TrimSpace(runtime.Str("query"))}
+ 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 {
+ req.Filter = filter
+ }
+ return req, nil
+}
+
+// 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 == ""
+}
+
+func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) error {
+ body, err := buildBotSearchBody(runtime)
+ if err != nil {
+ return err
+ }
+
+ 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 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,
+ Notice: respData.Notice,
+ }
+ 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))
+ })
+ if respData.Notice != "" && !botSearchStdoutCarriesEnvelope(runtime.Format) {
+ fmt.Fprintf(runtime.IO().ErrOut, "\nnotice: %s\n", respData.Notice)
+ }
+ if respData.HasMore && !botSearchStdoutCarriesEnvelope(runtime.Format) {
+ fmt.Fprintln(runtime.IO().ErrOut,
+ "\nhint: more matches exist; narrow with --has-chatted or a more specific --query")
+ }
+ 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)
+ bots = append(bots, searchBot{
+ OpenID: item.ID,
+ Name: name,
+ Description: description,
+ ChatID: item.MetaData.ChatID,
+ EnableJoinGroup: item.MetaData.EnableJoinGroup,
+ IsAgent: item.MetaData.IsAgent,
+ TenantID: item.MetaData.TenantID,
+ MatchSegments: segments,
+ })
+ }
+ return bots
+}
+
+func parseBotDisplayInfo(raw string) (name, description string, matchSegments []string) {
+ matchSegments = make([]string, 0)
+ for _, match := range displayInfoHighlightRE.FindAllStringSubmatch(raw, -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(html.UnescapeString(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 {
+ if candidate := stripTags(lines[0]); candidate != "" {
+ name = candidate
+ nameLine = 0
+ }
+ }
+ if name == "" {
+ for i, line := range lines {
+ if candidate := stripTags(line); candidate != "" {
+ name = candidate
+ nameLine = i
+ break
+ }
+ }
+ }
+ if nameLine >= 0 && nameLine+1 < len(lines) {
+ description = stripTags(lines[nameLine+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),
+ "is_agent": bot.IsAgent,
+ "enable_join_group": bot.EnableJoinGroup,
+ "open_id": bot.OpenID,
+ })
+ }
+ return rows
+}
diff --git a/shortcuts/contact/contact_search_bot_fanout.go b/shortcuts/contact/contact_search_bot_fanout.go
new file mode 100644
index 0000000000..e50b6a4f4c
--- /dev/null
+++ b/shortcuts/contact/contact_search_bot_fanout.go
@@ -0,0 +1,313 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+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 reuses the user fanout's query parsing, concurrency limit and
+// response summary types.
+
+type botFanoutResult struct {
+ Index int
+ Query string
+ Bots []searchBot
+ HasMore bool
+ Notice string
+ ErrMsg string // empty = success
+ Err error // original failure, kept for typed 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}
+}
+
+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"`
+}
+
+type botFanoutResponse struct {
+ Bots []fanoutBot `json:"bots"`
+ Queries []querySummary `json:"queries"`
+ Notice string `json:"notice,omitempty"`
+}
+
+// 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
+ }
+
+ 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)
+
+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)
+ 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: contactFanoutErrorSummary(err),
+ Err: err,
+ }
+ }
+ }()
+ 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 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
+ // 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 !botSearchStdoutCarriesEnvelope(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)
+ }
+ if qs.HasMore {
+ fmt.Fprintf(runtime.IO().ErrOut, "has_more: %q — more matches exist; narrow this keyword\n", qs.Query)
+ }
+ }
+ }
+ return nil
+}
+
+// buildBotFanoutFilter applies the same search scope and filter to every query.
+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),
+ "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..3d10694fc2
--- /dev/null
+++ b/shortcuts/contact/contact_search_bot_fanout_test.go
@@ -0,0 +1,632 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package contact
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "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"
+)
+
+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 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 {
+ 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)},
+ 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)
+ }
+ }
+}
+
+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 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(panicCause) }
+ registry.Register(boom)
+
+ 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.Fatal("a panicking query must fail the batch")
+ }
+ 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)
+ }
+ if stdout.Len() != 0 {
+ t.Fatalf("terminal failure must not write a success envelope: %s", stdout.String())
+ }
+ 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)
+ }
+ }
+}
+
+func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) {
+ factory, stdout, _, 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", "json", "--as", "user",
+ }, factory, stdout)
+ if err != nil {
+ t.Fatalf("one failing query must not fail the batch: %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())
+ }
+
+ 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 batch counters belong on stderr.
+ 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) {
+ 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)
+ }
+ }
+ _, 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) {
+ 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)
+ }
+}
+
+// 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
new file mode 100644
index 0000000000..1515913e2c
--- /dev/null
+++ b/shortcuts/contact/contact_search_bot_test.go
@@ -0,0 +1,712 @@
+// 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("queries", "", "")
+ 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)
+ }
+}
+
+// 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("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: "keyword missing",
+ wantParams: []string{"--query", "--queries"},
+ 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",
+ 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: "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)",
+ },
+ {
+ // 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"},
+ 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"},
+ 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 between 1 and 30",
+ },
+ {
+ 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 30",
+ },
+ {
+ 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 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 shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
+ },
+ }
+
+ 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)
+ 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)
+ }
+ })
+ }
+}
+
+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"}},
+ {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": " "}},
+ // 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 {
+ 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: "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"}`},
+ // 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 {
+ 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
+ wantName string
+ wantDescription string
+ wantSegments []string
+ }{
+ // Whole name highlighted, description on line two.
+ {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另一句简介", 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", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}},
+ // Single highlighted character in the middle of the name.
+ {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简介", 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)
+ 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.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.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)
+ }
+ 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)
+ }
+}
+
+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", "cursor_out")
+ registry.Register(stub)
+
+ err := mountAndRun(t, ContactSearchBot, []string{
+ "+search-bot", "--query", "甲乙", "--chat-ids", "oc_a,oc_b", "--has-chatted",
+ "--page-size", "25", "--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.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].ChatID != "oc_p2p" {
+ t.Fatalf("bots: %+v", envelope.Data.Bots)
+ }
+ registry.Verify(t)
+}
+
+func TestBotSearchIntegrationNeverSurfacesPageToken(t *testing.T) {
+ factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
+ // 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 {
+ 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("page_token must never be surfaced: %v", data)
+ }
+}
+
+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", "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", "chat_id", "match_segments"} {
+ if strings.Contains(stdout.String(), genericField) {
+ t.Errorf("pretty output exposed %q: %s", genericField, stdout.String())
+ }
+ }
+ // 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())
+ }
+ }
+}
+
+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", "chat_id", "match_segments"} {
+ if !strings.Contains(stdout.String(), field) {
+ t.Errorf("table output missing %q: %s", field, stdout.String())
+ }
+ }
+ // 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())
+ }
+ }
+}
+
+// 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())
+ 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 _, 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())
+ }
+ }
+ // 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(stdout.String(), "more matches exist") {
+ t.Fatalf("%s stdout must stay data-only: %s", format, stdout.String())
+ }
+ })
+ }
+}
+
+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", "--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) {
+ 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)
+ }
+}
+
+// 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())
+ }
+ })
+ }
+}
+
+// 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/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..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,12 +15,19 @@ metadata:
| 想做什么 | user 身份 | bot 身份 |
|---|---|---|
| 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.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 / agent / AI / 助手 / 机器人 / 智能体 / assistant 等明显特征时,反过来先搜机器人更快
+- 不确定的话两边都搜一下
+
## 典型场景
找张三给他发消息:先搜,确认 open_id,再发:
@@ -42,11 +49,20 @@ lark-cli contact user_profiles batch_query \
搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。
+## 搜索机器人 / 智能体
+
+`+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
+```
+
## 注意事项
- **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 类型**:`+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
new file mode 100644
index 0000000000..132d2d6f3a
--- /dev/null
+++ b/skills/lark-contact/references/lark-contact-search-bot.md
@@ -0,0 +1,60 @@
+# +search-bot
+
+按关键词搜索当前用户可见的机器人。仅支持 user 身份,需要 `search:bot` 权限。
+
+- ✅ 用关键词搜索机器人并获取 open_id
+- ✅ 一次搜索多个关键词(`--queries`)
+- ✅ 在指定群范围内搜索机器人(`--chat-ids`)
+
+## 参数
+
+必须传 `--query` 或 `--queries`。`--chat-ids` 指定搜索范围,`--has-chatted` 筛选已聊过的机器人;两者都不能单独使用。
+
+| Flag | 说明 |
+|---|---|
+| `--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
+lark-cli contact +search-bot --query '助手' --has-chatted --as user
+lark-cli contact +search-bot --queries '会议助手,日报助手,审批助手' --as user
+```
+
+## 输出
+
+| 字段 | 类型 | 说明 | 空值时 |
+|---|---|---|---|
+| `open_id` | string | 机器人 ID | 始终非空 |
+| `name` | string | 机器人名称 | 空字符串 |
+| `description` | string | 机器人简介 | 字段省略 |
+| `chat_id` | string | 与机器人的单聊 ID | 空字符串 |
+| `enable_join_group` | bool | 是否允许加入群聊 | — |
+| `is_agent` | bool | 是否是智能体 | — |
+| `tenant_id` | string | 租户标识 | 字段省略 |
+| `match_segments` | string[] | 命中的文本片段 | 无命中时为 `[]` |
+
+### 没有分页
+
+不支持分页。`has_more=true` 时改用更具体的关键词,或调整搜索范围。
+
+### 多条命中怎么选
+
+命中多个机器人时,结合 `description` 和 `is_agent` 判断。后续要发消息或拉群时,让用户确认目标,不要直接选择第一条。
+
+```bash
+lark-cli contact +search-bot --query '会议助手' \
+ --jq '.data.bots[] | select((.description // "") | contains("<功能关键词>"))' --as user
+```
+
+## fanout(`--queries`)
+
+输出为 `{bots[], queries[], notice?}`。`has_more` 只出现在每个关键词的结果中。
+
+- `bots[].matched_query`:该结果对应的关键词
+- `queries[]`:每个关键词的执行结果,格式为 `{query, error?, has_more, notice?}`
+- 部分关键词失败时保留其他结果;全部失败时命令报错
+- `--chat-ids` 和 `--has-chatted` 对所有关键词生效
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..9338f4cbdd
--- /dev/null
+++ b/tests/cli_e2e/contact/contact_search_bot_workflow_test.go
@@ -0,0 +1,76 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package contact
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ clie2e "github.com/larksuite/cli/tests/cli_e2e"
+ "github.com/stretchr/testify/require"
+ "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)
+
+ 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 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)
+
+ 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("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)
+ }
+}
+
+// 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 e4d852f740..64a630925f 100644
--- a/tests/cli_e2e/contact/coverage.md
+++ b/tests/cli_e2e/contact/coverage.md
@@ -1,13 +1,15 @@
# 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 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.
## Command Table
@@ -15,4 +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; 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 |
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..c94c75f43a
--- /dev/null
+++ b/tests/cli_e2e/dryrun/contact_search_bot_dryrun_test.go
@@ -0,0 +1,48 @@
+// 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",
+ "--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, "助手", 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)
+}