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) +}