diff --git a/internal/httpmock/registry.go b/internal/httpmock/registry.go index 2bdec941..ef32e45d 100644 --- a/internal/httpmock/registry.go +++ b/internal/httpmock/registry.go @@ -25,10 +25,26 @@ type Stub struct { Headers http.Header // optional full response headers (takes precedence over ContentType) matched bool + // BodyFilter (optional): match only when the captured request body satisfies + // this predicate. Used to disambiguate multiple stubs that share a URL. + BodyFilter func([]byte) bool + + // OnMatch (optional): runs synchronously after the stub matches but before + // the response is composed. Used in tests to inject panics or count + // in-flight goroutines. + OnMatch func(req *http.Request) + + // Reusable (optional): when true, the stub stays available for further + // matches after the first hit. Each match appends to CapturedBodies. + Reusable bool + // CapturedHeaders records the request headers of the matched request. // Populated after RoundTrip matches this stub. CapturedHeaders http.Header CapturedBody []byte + // CapturedBodies records every captured request body when Reusable is set. + // (CapturedBody continues to record the most recent capture for back-compat.) + CapturedBodies [][]byte } // Registry records stubs and implements http.RoundTripper. @@ -51,8 +67,43 @@ func (r *Registry) Register(s *Stub) { func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) { urlStr := req.URL.String() + // Read body once up-front so BodyFilter can inspect it without consuming + // the original reader; restore for downstream consumers afterwards. + // http.RoundTripper requires us to close the original body. + var capturedBody []byte + if req.Body != nil { + var err error + capturedBody, err = io.ReadAll(req.Body) + _ = req.Body.Close() + if err != nil { + return nil, fmt.Errorf("httpmock: read request body: %w", err) + } + req.Body = io.NopCloser(bytes.NewReader(capturedBody)) + } + + matched := r.match(req, urlStr, capturedBody) + + if matched != nil { + // Restore body again in case OnMatch wants to read it. + req.Body = io.NopCloser(bytes.NewReader(capturedBody)) + if matched.OnMatch != nil { + matched.OnMatch(req) + } + resp, err := stubResponse(matched) + if err != nil { + return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err) + } + return resp, nil + } + return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL) +} + +// match selects the first stub whose Method/URL/BodyFilter all match the +// request, mutates its capture state, and returns it. defer-Unlock guarantees +// a panicking user-supplied BodyFilter cannot leak the mutex. +func (r *Registry) match(req *http.Request, urlStr string, capturedBody []byte) *Stub { r.mu.Lock() - var matched *Stub + defer r.mu.Unlock() for _, s := range r.stubs { if s.matched { continue @@ -63,25 +114,18 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) { if s.URL != "" && !strings.Contains(urlStr, s.URL) { continue } - s.matched = true + if s.BodyFilter != nil && !s.BodyFilter(capturedBody) { + continue + } + if !s.Reusable { + s.matched = true + } s.CapturedHeaders = req.Header.Clone() - if req.Body != nil { - s.CapturedBody, _ = io.ReadAll(req.Body) - req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody)) - } - matched = s - break + s.CapturedBody = capturedBody + s.CapturedBodies = append(s.CapturedBodies, capturedBody) + return s } - r.mu.Unlock() - - if matched != nil { - resp, err := stubResponse(matched) - if err != nil { - return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err) - } - return resp, nil - } - return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL) + return nil } // Verify asserts all stubs were matched. @@ -90,9 +134,14 @@ func (r *Registry) Verify(t testing.TB) { r.mu.Lock() defer r.mu.Unlock() for _, s := range r.stubs { - if !s.matched { - t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL) + if s.matched { + continue } + // Reusable stubs never set s.matched; treat any captured hit as a match. + if s.Reusable && len(s.CapturedBodies) > 0 { + continue + } + t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL) } } diff --git a/internal/output/format.go b/internal/output/format.go index a05a2ea1..7bb4088f 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -15,6 +15,7 @@ import ( var knownArrayFields = []string{ "items", "files", "events", "rooms", "records", "nodes", "members", "departments", "calendar_list", "acl_list", "freebusy_list", + "users", } // FindArrayField finds the primary array field in a response's data object. diff --git a/shortcuts/contact/contact_search_user.go b/shortcuts/contact/contact_search_user.go index b5e72dc2..3aacce14 100644 --- a/shortcuts/contact/contact_search_user.go +++ b/shortcuts/contact/contact_search_user.go @@ -123,7 +123,7 @@ type searchUser struct { P2PChatID string `json:"p2p_chat_id"` HasChatted bool `json:"has_chatted"` Department string `json:"department"` - Signature string `json:"signature"` + Signature string `json:"signature,omitempty"` ChatRecencyHint string `json:"chat_recency_hint"` MatchSegments []string `json:"match_segments"` } @@ -150,18 +150,38 @@ var ContactSearchUser = common.Shortcut{ {Name: "left-organization", Type: "bool", Desc: "restrict to users who have left the organization (omit to disable; =false rejected)"}, {Name: "lang", Desc: "override locale for localized_name (e.g. zh_cn, en_us)"}, {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 users[] with matched_query plus a queries[] sidecar"}, }, Tips: []string{ - "Keyword search: lark-cli contact +search-user --query 'alice' --format json", - "Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me' --format json", - "Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted --format json", + "Keyword search: lark-cli contact +search-user --query 'alice'", + "Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'", + "Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted", "Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users", + "Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'", "open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateSearchUser(runtime) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" { + queries := parseAndDedupQueries(raw) + filter, err := buildFanoutFilter(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + api := common.NewDryRunAPI() + for _, q := range queries { + body := &searchUserAPIRequest{Query: q} + if filter != nil { + body.Filter = filter + } + api.POST(searchUserURL). + Params(map[string]interface{}{"page_size": runtime.Int("page-size")}). + Body(body) + } + return api + } body, err := buildSearchUserBody(runtime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) @@ -175,6 +195,13 @@ var ContactSearchUser = common.Shortcut{ } func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("queries")) != "" { + return executeSearchUserFanout(ctx, runtime) + } + return executeSearchUserSingle(ctx, runtime) +} + +func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error { body, err := buildSearchUserBody(runtime) if err != nil { return err @@ -347,10 +374,32 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea func validateSearchUser(runtime *common.RuntimeContext) error { if !hasAnySearchInput(runtime) { return common.FlagErrorf( - "specify at least one of --query, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization", + "specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization", ) } + queriesRaw := strings.TrimSpace(runtime.Str("queries")) + if queriesRaw != "" { + if strings.TrimSpace(runtime.Str("query")) != "" { + return common.FlagErrorf("--query and --queries are mutually exclusive") + } + if strings.TrimSpace(runtime.Str("user-ids")) != "" { + return common.FlagErrorf("--user-ids and --queries are mutually exclusive") + } + queries := parseAndDedupQueries(queriesRaw) + if len(queries) == 0 { + return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw) + } + if len(queries) > maxFanoutQueries { + return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)) + } + for _, q := range queries { + if utf8.RuneCountInString(q) > maxSearchUserQueryChars { + return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars) + } + } + } + if q := strings.TrimSpace(runtime.Str("query")); q != "" { if utf8.RuneCountInString(q) > maxSearchUserQueryChars { return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars) @@ -399,6 +448,9 @@ func hasAnySearchInput(runtime *common.RuntimeContext) bool { if strings.TrimSpace(runtime.Str("query")) != "" { return true } + if strings.TrimSpace(runtime.Str("queries")) != "" { + return true + } if strings.TrimSpace(runtime.Str("user-ids")) != "" { return true } diff --git a/shortcuts/contact/contact_search_user_fanout.go b/shortcuts/contact/contact_search_user_fanout.go new file mode 100644 index 00000000..ec1d5e84 --- /dev/null +++ b/shortcuts/contact/contact_search_user_fanout.go @@ -0,0 +1,275 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +const ( + maxFanoutQueries = 20 + fanoutConcurrency = 5 +) + +// parseAndDedupQueries splits the raw CSV, trims whitespace, drops empty +// entries, and deduplicates case-sensitively while preserving first-occurrence +// order. +func parseAndDedupQueries(raw string) []string { + parts := common.SplitCSV(raw) + seen := make(map[string]bool, len(parts)) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" || seen[p] { + continue + } + seen[p] = true + out = append(out, p) + } + return out +} + +type fanoutResult struct { + Index int + Query string + Users []searchUser + HasMore bool + ErrMsg string // empty = success + ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error +} + +// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv +// because that summary lives on stderr and never corrupts the csv stream on +// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate +// for its refine hint, so adding csv here doesn't regress that path. +func isFanoutSummaryFormat(format string) bool { + return format == "pretty" || format == "table" || format == "csv" +} + +// runOneQuery converts every failure mode (transport, HTTP status, parse, +// API code) into an ErrMsg string instead of returning a Go error. The +// fanout dispatcher (Task 6) relies on this so a single failed query never +// short-circuits the remaining workers. +func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string, + filter *searchUserAPIFilter) fanoutResult { + // 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 fanoutResult{Index: index, Query: query, ErrMsg: err.Error()} + } + + body := &searchUserAPIRequest{Query: query} + if filter != nil { + body.Filter = filter + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: searchUserURL, + Body: body, + QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}}, + }) + if err != nil { + return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()} + } + if apiResp.StatusCode != http.StatusOK { + body := strings.TrimSpace(string(apiResp.RawBody)) + const maxBody = 200 + if len(body) > maxBody { + body = body[:maxBody] + "..." + } + msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode)) + if body != "" { + msg = fmt.Sprintf("%s: %s", msg, body) + } + return fanoutResult{Index: index, Query: query, + ErrMsg: msg, + ErrCode: apiResp.StatusCode} + } + + var resp searchUserAPIEnvelope + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return fanoutResult{Index: index, Query: query, + ErrMsg: fmt.Sprintf("parse response failed: %v", err)} + } + if resp.Code != 0 { + return fanoutResult{Index: index, Query: query, + ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg), + ErrCode: resp.Code} + } + + users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand) + return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore} +} + +type fanoutUser struct { + searchUser + MatchedQuery string `json:"matched_query"` +} + +type querySummary struct { + Query string `json:"query"` + Error string `json:"error,omitempty"` + HasMore bool `json:"has_more"` +} + +type fanoutResponse struct { + Users []fanoutUser `json:"users"` + Queries []querySummary `json:"queries"` +} + +// buildFanoutResponse walks results by Index (input order), flattens users[] +// with matched_query, lists every input in queries[] (including successes), +// and returns an error only when every query failed. The error wraps the +// first failing query's ErrMsg so the CLI exits non-zero on full failure. +func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) { + indexed := make([]fanoutResult, len(queries)) + for _, r := range results { + indexed[r.Index] = r + } + + out := &fanoutResponse{ + Users: make([]fanoutUser, 0), + Queries: make([]querySummary, 0, len(queries)), + } + failed := 0 + var firstErrMsg, firstErrQuery string + var firstErrCode int + for i, r := range indexed { + out.Queries = append(out.Queries, querySummary{ + Query: queries[i], + Error: r.ErrMsg, + HasMore: r.HasMore, + }) + if r.ErrMsg != "" { + failed++ + if firstErrMsg == "" { + firstErrMsg = r.ErrMsg + firstErrQuery = queries[i] + firstErrCode = r.ErrCode + } + continue + } + for _, u := range r.Users { + out.Users = append(out.Users, fanoutUser{searchUser: u, 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) + // Only the HTTP-status / Lark-API-code branches in runOneQuery populate + // ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0 + // means success in the Lark protocol, so don't pretend it's an API error + // when we have nothing structured to report. + if firstErrCode != 0 { + return nil, output.ErrAPI(firstErrCode, msg, "") + } + return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "") + } + return out, nil +} + +func executeSearchUserFanout(ctx context.Context, runtime *common.RuntimeContext) error { + queries := parseAndDedupQueries(runtime.Str("queries")) + + filter, err := buildFanoutFilter(runtime) + if err != nil { + return err + } + + results := make([]fanoutResult, len(queries)) + var wg sync.WaitGroup + sem := make(chan struct{}, fanoutConcurrency) + + for i, q := range queries { + wg.Add(1) + sem <- struct{}{} + go func(i int, q string) { + defer wg.Done() + defer func() { <-sem }() + defer func() { + if r := recover(); r != nil { + results[i] = fanoutResult{ + Index: i, + Query: q, + ErrMsg: fmt.Sprintf("internal error: %v", r), + } + } + }() + results[i] = runOneQuery(ctx, runtime, i, q, filter) + }(i, q) + } + wg.Wait() + + resp, err := buildFanoutResponse(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.Users)}, func(w io.Writer) { + if len(resp.Users) == 0 { + fmt.Fprintln(w, "No users found.") + return + } + output.PrintTable(w, prettyFanoutUserRows(resp.Users)) + }) + + if isFanoutSummaryFormat(runtime.Format) { + fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total users; %d failed, %d with has_more\n", + len(queries), len(resp.Users), failed, hasMoreCount) + } + return nil +} + +func buildFanoutFilter(runtime *common.RuntimeContext) (*searchUserAPIFilter, error) { + filter := &searchUserAPIFilter{} + hasFilter := false + for _, bf := range searchUserBoolFilters { + if runtime.Cmd.Flags().Changed(bf.Flag) && runtime.Bool(bf.Flag) { + bf.Apply(filter) + hasFilter = true + } + } + if !hasFilter { + return nil, nil + } + return filter, nil +} + +func prettyFanoutUserRows(users []fanoutUser) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(users)) + for _, u := range users { + rows = append(rows, map[string]interface{}{ + "matched_query": u.MatchedQuery, + "localized_name": u.LocalizedName, + "department": common.TruncateStr(u.Department, 50), + "enterprise_email": u.EnterpriseEmail, + "has_chatted": u.HasChatted, + "chat_recency_hint": u.ChatRecencyHint, + "open_id": u.OpenID, + }) + } + return rows +} diff --git a/shortcuts/contact/contact_search_user_test.go b/shortcuts/contact/contact_search_user_test.go index fc6b24b1..a3fd4e1a 100644 --- a/shortcuts/contact/contact_search_user_test.go +++ b/shortcuts/contact/contact_search_user_test.go @@ -5,10 +5,14 @@ package contact import ( "bytes" + "context" "encoding/json" "fmt" + "net/http" "strings" + "sync/atomic" "testing" + "time" "unicode/utf8" "github.com/larksuite/cli/internal/cmdutil" @@ -620,6 +624,46 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) { } } +// Most users have no signature; the field is omitempty so an empty value +// must not appear at all in the JSON, not as "" — agents shouldn't have to +// distinguish "absent" from "empty string". +func TestSearchUser_Integration_EmptySignatureOmitted(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/contact/v3/users/search", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "id": "ou_a", + "meta_data": map[string]interface{}{ + "i18n_names": map[string]interface{}{"zh_cn": "无签名用户"}, + "mail_address": "x@example.com", + "description": "", + }, + }, + }, + "has_more": false, + }, + }, + }) + + err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "json", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json: %v\nstdout=%s", err, stdout.String()) + } + users := got["data"].(map[string]interface{})["users"].([]interface{}) + u := users[0].(map[string]interface{}) + if _, present := u["signature"]; present { + t.Errorf(`signature must be absent (not "") when empty; got %v`, u["signature"]) + } +} + func TestSearchUser_Integration_NDJSONHasNoRefineHint(t *testing.T) { f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) reg.Register(&httpmock.Stub{ @@ -808,6 +852,345 @@ func TestSearchUser_Integration_PageSizeFlowsToQuery(t *testing.T) { reg.Verify(t) } +func newSearchUserTestCommandWithQueries() *cobra.Command { + cmd := newSearchUserTestCommand() + cmd.Flags().String("queries", "", "") + return cmd +} + +func TestValidateQueries_QueryAndQueriesMutex(t *testing.T) { + cmd := newSearchUserTestCommandWithQueries() + _ = cmd.Flags().Set("query", "alice") + _ = cmd.Flags().Set("queries", "bob,carol") + rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig()) + err := validateSearchUser(rt) + if err == nil || !strings.Contains(err.Error(), "--query and --queries are mutually exclusive") { + t.Fatalf("expected mutex error, got %v", err) + } +} + +func TestValidateQueries_UserIDsAndQueriesMutex(t *testing.T) { + cmd := newSearchUserTestCommandWithQueries() + _ = cmd.Flags().Set("user-ids", "ou_a") + _ = cmd.Flags().Set("queries", "bob") + rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig()) + err := validateSearchUser(rt) + if err == nil || !strings.Contains(err.Error(), "--user-ids and --queries are mutually exclusive") { + t.Fatalf("expected mutex error, got %v", err) + } +} + +func TestValidateQueries_AllSeparators_Errors(t *testing.T) { + for _, raw := range []string{",,,", " , , ", ","} { + cmd := newSearchUserTestCommandWithQueries() + _ = cmd.Flags().Set("queries", raw) + rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig()) + err := validateSearchUser(rt) + if err == nil || !strings.Contains(err.Error(), "no valid query parsed") { + t.Fatalf("raw=%q: expected 'no valid query parsed' error, got %v", raw, err) + } + } +} + +func TestValidateQueries_OverLength_Errors(t *testing.T) { + cmd := newSearchUserTestCommandWithQueries() + long := strings.Repeat("a", 51) + _ = cmd.Flags().Set("queries", "short,"+long) + rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig()) + err := validateSearchUser(rt) + if err == nil || !strings.Contains(err.Error(), "exceeds 50 characters") { + t.Fatalf("expected length error mentioning 50, got %v", err) + } +} + +func TestValidateQueries_Over20_Errors(t *testing.T) { + cmd := newSearchUserTestCommandWithQueries() + parts := make([]string, 21) + for i := range parts { + parts[i] = fmt.Sprintf("q%02d", i) + } + _ = cmd.Flags().Set("queries", strings.Join(parts, ",")) + rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig()) + err := validateSearchUser(rt) + if err == nil || !strings.Contains(err.Error(), "must be at most 20 entries") { + t.Fatalf("expected 20-cap error, got %v", err) + } +} + +func TestParseQueries_TrimAndSkipEmpty(t *testing.T) { + got := parseAndDedupQueries("a, ,b ,") + want := []string{"a", "b"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("parseAndDedupQueries: got %v, want %v", got, want) + } +} + +func TestParseQueries_DedupCaseSensitive(t *testing.T) { + got := parseAndDedupQueries("alice,Alice,alice") + want := []string{"alice", "Alice"} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Errorf("got %v, want %v (case-sensitive dedup keeps first-occurrence order)", got, want) + } +} + +func TestExecuteSingleQuery_OutputUnchanged(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + reg.Register(searchUserStub()) + + err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "张三", "--format", "json", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json: %v", err) + } + data, _ := got["data"].(map[string]interface{}) + if _, hasQueries := data["queries"]; hasQueries { + t.Errorf("single-query mode must NOT emit data.queries; got=%v", data) + } + users, _ := data["users"].([]interface{}) + if len(users) != 1 { + t.Fatalf("users len = %d, want 1", len(users)) + } + u, _ := users[0].(map[string]interface{}) + if _, hasMatched := u["matched_query"]; hasMatched { + t.Errorf("single-query mode users[] must NOT carry matched_query; got=%v", u) + } + if _, hasTopHasMore := data["has_more"]; !hasTopHasMore { + t.Errorf("single-query mode must keep top-level data.has_more; data=%v", data) + } +} + +// runOneQueryRuntime wires a Factory-backed RuntimeContext bound to the test +// command's flag set, so runOneQuery can be exercised directly without going +// through the cobra dispatcher. Mirrors what mountAndRun would build, minus +// the parent-command plumbing the worker doesn't need. +func runOneQueryRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + f, _, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + cmd := newSearchUserTestCommand() + rt := common.TestNewRuntimeContextForAPI(context.Background(), cmd, searchUserDefaultConfig(), f, core.AsUser) + return rt, reg +} + +func TestRunOneQuery_Success(t *testing.T) { + rt, reg := runOneQueryRuntime(t) + reg.Register(searchUserStub()) + + got := runOneQuery(context.Background(), rt, 0, "张三", nil) + if got.ErrMsg != "" { + t.Fatalf("unexpected ErrMsg: %q", got.ErrMsg) + } + if got.Index != 0 || got.Query != "张三" { + t.Errorf("Index/Query mismatch: %+v", got) + } + if len(got.Users) != 1 || got.Users[0].OpenID != "ou_a" { + t.Errorf("Users mismatch: %+v", got.Users) + } + if got.HasMore { + t.Errorf("HasMore should be false") + } +} + +func TestRunOneQuery_APINonZeroCode(t *testing.T) { + rt, reg := runOneQueryRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: searchUserURL, + Body: map[string]interface{}{"code": 99991663, "msg": "rate limited"}, + }) + + got := runOneQuery(context.Background(), rt, 3, "alice", nil) + if got.Index != 3 || got.Query != "alice" { + t.Errorf("Index/Query mismatch: %+v", got) + } + if got.ErrMsg != "API 99991663: rate limited" { + t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg) + } + if got.Users != nil || got.HasMore { + t.Errorf("on error, Users/HasMore must be zero values; got %+v", got) + } +} + +func TestRunOneQuery_HTTPNon200(t *testing.T) { + rt, reg := runOneQueryRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: searchUserURL, + Status: 503, + Body: map[string]interface{}{"reason": "upstream_unavailable"}, + }) + + got := runOneQuery(context.Background(), rt, 1, "bob", nil) + if !strings.HasPrefix(got.ErrMsg, "HTTP 503 Service Unavailable: ") { + t.Errorf("ErrMsg should start with status line; got %q", got.ErrMsg) + } + if !strings.Contains(got.ErrMsg, "upstream_unavailable") { + t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg) + } + if got.ErrCode != 503 { + t.Errorf("ErrCode = %d, want 503", got.ErrCode) + } +} + +func TestRunOneQuery_HTTPNon200_BodyTruncated(t *testing.T) { + rt, reg := runOneQueryRuntime(t) + long := strings.Repeat("x", 1000) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: searchUserURL, + Status: 500, + Body: map[string]interface{}{"detail": long}, + }) + + got := runOneQuery(context.Background(), rt, 0, "alice", nil) + if !strings.HasSuffix(got.ErrMsg, "...") { + t.Errorf("oversized body should be truncated with '...' suffix; got %q", got.ErrMsg) + } + if len(got.ErrMsg) > 300 { + t.Errorf("ErrMsg %d chars exceeds reasonable budget; got %q", len(got.ErrMsg), got.ErrMsg) + } +} + +// SDK-level transport / envelope-unmarshal failures arrive as Go errors from +// runtime.DoAPI; the worker converts them by calling err.Error() rather than +// adding its own prefix, so the assertion here is "ErrMsg is non-empty and +// preserves the underlying message" — the exact text comes from the SDK. +func TestRunOneQuery_TransportError(t *testing.T) { + rt, reg := runOneQueryRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: searchUserURL, + RawBody: []byte("{not-json"), + }) + + got := runOneQuery(context.Background(), rt, 2, "carol", nil) + if got.ErrMsg == "" { + t.Fatalf("expected non-empty ErrMsg for malformed body") + } + if got.Index != 2 || got.Query != "carol" { + t.Errorf("Index/Query mismatch: %+v", got) + } + if got.Users != nil || got.HasMore { + t.Errorf("on error, Users/HasMore must be zero values; got %+v", got) + } +} + +func TestFanoutAssemble_OrderAndShape(t *testing.T) { + results := []fanoutResult{ + {Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true}, + {Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}, HasMore: false}, + {Index: 2, Query: "carol", ErrMsg: "API 1: nope"}, + } + resp, err := buildFanoutResponse([]string{"alice", "bob", "carol"}, results) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Users) != 3 { + t.Fatalf("Users length: got %d, want 3 (carol failed → 0 users)", len(resp.Users)) + } + if resp.Users[0].OpenID != "ou_a1" || resp.Users[0].MatchedQuery != "alice" { + t.Errorf("Users[0]: got %+v", resp.Users[0]) + } + if resp.Users[1].OpenID != "ou_a2" || resp.Users[1].MatchedQuery != "alice" { + t.Errorf("Users[1]: got %+v", resp.Users[1]) + } + if resp.Users[2].OpenID != "ou_b" || resp.Users[2].MatchedQuery != "bob" { + t.Errorf("Users[2]: got %+v", resp.Users[2]) + } + if len(resp.Queries) != 3 { + t.Fatalf("Queries length: got %d, want 3 (full enumeration)", len(resp.Queries)) + } + want := []querySummary{ + {Query: "alice", Error: "", HasMore: false}, + {Query: "bob", Error: "", HasMore: true}, + {Query: "carol", Error: "API 1: nope", HasMore: false}, + } + for i, w := range want { + if resp.Queries[i] != w { + t.Errorf("Queries[%d]: got %+v, want %+v", i, resp.Queries[i], w) + } + } +} + +func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) { + results := []fanoutResult{ + {Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit"}, + {Index: 1, Query: "bob", ErrMsg: "HTTP 500 Internal Server Error"}, + } + _, err := buildFanoutResponse([]string{"alice", "bob"}, results) + if err == nil { + t.Fatalf("expected error when all queries failed") + } + if !strings.Contains(err.Error(), "rate limit") { + t.Errorf("expected first error (rate limit) to be returned; got %v", err) + } + // Document the count is part of the message — agents grep for it. + if !strings.Contains(err.Error(), "all 2 queries failed") { + t.Errorf("expected 'all 2 queries failed' substring; got %v", err) + } +} + +// Codes from the first failure must propagate through output.ErrAPI so the +// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit) +// instead of 0, which would mean "success" in the Lark protocol. +func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) { + results := []fanoutResult{ + {Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663}, + {Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500}, + } + _, err := buildFanoutResponse([]string{"alice", "bob"}, results) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "rate limit") { + t.Errorf("error should contain first ErrMsg; got %v", err) + } +} + +func TestFanoutAssemble_PartialFailureOK(t *testing.T) { + results := []fanoutResult{ + {Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a"}}}, + {Index: 1, Query: "bob", ErrMsg: "API 5: not found"}, + } + resp, err := buildFanoutResponse([]string{"alice", "bob"}, results) + if err != nil { + t.Fatalf("partial failure must NOT be a hard error; got %v", err) + } + if len(resp.Users) != 1 { + t.Errorf("Users: got %d, want 1", len(resp.Users)) + } + if resp.Queries[1].Error != "API 5: not found" { + t.Errorf("Queries[1].Error: got %q", resp.Queries[1].Error) + } +} + +func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) { + results := []fanoutResult{ + {Index: 0, Query: "alice", HasMore: true}, + } + resp, err := buildFanoutResponse([]string{"alice"}, results) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + raw, _ := json.Marshal(resp) + var asMap map[string]interface{} + if err := json.Unmarshal(raw, &asMap); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := asMap["has_more"]; ok { + t.Errorf("fanoutResponse must not have top-level has_more; got %v", asMap) + } + if _, ok := asMap["users"]; !ok { + t.Errorf("fanoutResponse missing users") + } + if _, ok := asMap["queries"]; !ok { + t.Errorf("fanoutResponse missing queries") + } +} + // Verifies that with the auto-pagination flags removed, --page-all / --page-limit // are no longer accepted. cobra must reject the unknown flag at parse time — // no stub is registered because the command should never reach the API. @@ -827,3 +1210,341 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) { }) } } + +func TestFanout_FilterAppliedToEachQuery(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/contact/v3/users/search", + Reusable: true, + Body: map[string]interface{}{"code": 0, "msg": "ok", + "data": map[string]interface{}{"items": []interface{}{}, "has_more": false}}, + } + reg.Register(stub) + + err := mountAndRun(t, ContactSearchUser, []string{ + "+search-user", "--queries", "alice,bob", "--has-chatted", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(stub.CapturedBodies) < 2 { + t.Fatalf("expected ≥2 captured request bodies, got %d", len(stub.CapturedBodies)) + } + bodyByQuery := map[string]map[string]interface{}{} + 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) + } + bodyByQuery[body["query"].(string)] = body + filter, _ := body["filter"].(map[string]interface{}) + if filter == nil || filter["has_contact"] != true { + t.Errorf("req %d (query=%v): expected filter.has_contact=true; got body=%v", i, body["query"], body) + } + } + if _, ok := bodyByQuery["alice"]; !ok { + t.Errorf("missing request for query=alice; captured=%v", bodyByQuery) + } + if _, ok := bodyByQuery["bob"]; !ok { + t.Errorf("missing request for query=bob; captured=%v", bodyByQuery) + } +} + +func TestFanout_PartialFailure_ExitZero(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/contact/v3/users/search", + BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) }, + Body: map[string]interface{}{"code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "ou_a"}}, + "has_more": false, + }}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/contact/v3/users/search", + BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"bob"`) }, + Status: 500, + Body: map[string]interface{}{}, + }) + err := mountAndRun(t, ContactSearchUser, []string{ + "+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("partial failure should NOT propagate as error; got %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json: %v\nstdout=%s", err, stdout.String()) + } + data := got["data"].(map[string]interface{}) + users := data["users"].([]interface{}) + if len(users) != 1 { + t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String()) + } + queries := data["queries"].([]interface{}) + if len(queries) != 2 { + t.Fatalf("queries: expected 2, got %d", len(queries)) + } + q1 := queries[1].(map[string]interface{}) + if !strings.HasPrefix(q1["error"].(string), "HTTP 500") { + t.Errorf("queries[1].error: got %q", q1["error"]) + } +} + +func TestFanout_AllFailed_ExitNonZero(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/contact/v3/users/search", + Reusable: true, + Status: 500, Body: map[string]interface{}{"reason": "boom"}, + }) + err := mountAndRun(t, ContactSearchUser, []string{ + "+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatalf("expected error when all queries failed") + } + // First failure's HTTP code (500) and a digestible reason must propagate + // so agents can classify (vs. a generic ExitInternal masking the upstream). + msg := err.Error() + if !strings.Contains(msg, "500") { + t.Errorf("error must propagate first failure's HTTP 500 code; got %q", msg) + } + if !strings.Contains(msg, "all 2 queries failed") { + t.Errorf("error must indicate the all-failed mode; got %q", msg) + } +} + +func TestFanout_ConcurrencyLimitFive(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + + var inFlight, peak int32 + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/contact/v3/users/search", + Reusable: true, + 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) + }, + Body: map[string]interface{}{"code": 0, "msg": "ok", + "data": map[string]interface{}{"items": []interface{}{}, "has_more": false}}, + }) + + queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"} + err := mountAndRun(t, ContactSearchUser, []string{ + "+search-user", "--queries", strings.Join(queries, ","), + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + if peak > 5 { + t.Errorf("concurrency peak = %d, want ≤ 5", peak) + } + if peak < 2 { + t.Errorf("concurrency peak = %d, want ≥ 2 (test should observe parallelism)", peak) + } +} + +func TestFanout_PanicRecovery(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/contact/v3/users/search", + BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"boom"`) }, + OnMatch: func(req *http.Request) { + panic("synthetic test panic") + }, + Body: map[string]interface{}{}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/contact/v3/users/search", + Reusable: true, + Body: map[string]interface{}{"code": 0, "msg": "ok", + "data": map[string]interface{}{"items": []interface{}{}, "has_more": false}}, + }) + + err := mountAndRun(t, ContactSearchUser, []string{ + "+search-user", "--queries", "ok,boom,fine", "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("partial panic must not bubble; got %v", err) + } + var got map[string]interface{} + _ = json.Unmarshal(stdout.Bytes(), &got) + queries := got["data"].(map[string]interface{})["queries"].([]interface{}) + q1 := queries[1].(map[string]interface{}) + if !strings.HasPrefix(q1["error"].(string), "internal error:") { + t.Errorf("queries[1].error: expected 'internal error:' prefix, got %q", q1["error"]) + } + for _, marker := range []string{"goroutine ", ".go:", "runtime."} { + if strings.Contains(stderr.String(), marker) { + t.Errorf("stderr leaked stack-trace marker %q; got=%s", marker, stderr.String()) + } + } +} + +func TestFanout_MatchedQueryFidelity(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/contact/v3/users/search", + Reusable: true, + Body: map[string]interface{}{"code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "ou_x"}}, + "has_more": false, + }}, + }) + err := mountAndRun(t, ContactSearchUser, []string{ + "+search-user", "--queries", "张三,Alice 王", "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + var got map[string]interface{} + _ = json.Unmarshal(stdout.Bytes(), &got) + users := got["data"].(map[string]interface{})["users"].([]interface{}) + if len(users) != 2 { + t.Fatalf("users: got %d, want 2", len(users)) + } + want := []string{"张三", "Alice 王"} + for i, w := range want { + mq := users[i].(map[string]interface{})["matched_query"] + if mq != w { + t.Errorf("users[%d].matched_query: got %v, want %q (must be original input verbatim)", i, mq, w) + } + } +} + +func TestFanout_NDJSONStdoutClean(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/contact/v3/users/search", + Reusable: true, + Body: map[string]interface{}{"code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "ou_a"}}, + "has_more": false, + }}, + }) + err := mountAndRun(t, ContactSearchUser, []string{ + "+search-user", "--queries", "a,a,b", "--format", "ndjson", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, marker := range []string{"queries,", "total users", "with has_more"} { + if strings.Contains(stdout.String(), marker) { + t.Errorf("ndjson stdout must not contain %q; got=%q", marker, stdout.String()) + } + } + _ = stderr +} + +func TestFanout_CSVHasMatchedQueryColumn(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/contact/v3/users/search", + Reusable: true, + Body: map[string]interface{}{"code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "ou_a"}}, + "has_more": false, + }}, + }) + err := mountAndRun(t, ContactSearchUser, []string{ + "+search-user", "--queries", "alice,bob", "--format", "csv", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(stdout.String(), "matched_query") { + t.Errorf("csv stdout must include matched_query column; got=%q", stdout.String()) + } + if !strings.Contains(stderr.String(), "queries") || !strings.Contains(stderr.String(), "total users") { + t.Errorf("csv summary should land on stderr; got=%q", stderr.String()) + } +} + +func TestFanout_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig()) + + err := mountAndRun(t, ContactSearchUser, []string{ + "+search-user", "--queries", "alice,bob", "--has-chatted", "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + out := stdout.String() + for _, want := range []string{"alice", "bob", "POST", "/contact/v3/users/search", "has_contact"} { + if !strings.Contains(out, want) { + t.Errorf("dry-run output missing %q; got=%q", want, out) + } + } + // One DryRunAPI description per query. + if strings.Count(out, "/contact/v3/users/search") < 2 { + t.Errorf("dry-run should describe ≥2 API calls (one per query); got=%q", out) + } +} + +// Spec §7 promises single-query --query mode is "零变化". The fanout summary +// hint was broadened to csv (good — stderr can carry it without corrupting +// the csv stream on stdout); the single-query refine hint must NOT inherit +// that broadening, since pre-fanout it only fired on pretty/table. +func TestSearchUser_Integration_CSVSingleQueryNoRefineHint(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/contact/v3/users/search", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "ou_a"}}, + "has_more": true, + "page_token": "tok_next", + }, + }, + }) + err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "csv", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + if strings.Contains(stderr.String(), "refine") { + t.Errorf("single-query --format csv must NOT emit the refine hint; got stderr=%q", stderr.String()) + } +} + +// A pre-canceled ctx must be observed by runOneQuery before it dispatches the +// HTTP call. The error string is exactly "context canceled" because that's +// what context.Context.Err().Error() returns — agents may grep for it. +func TestRunOneQuery_CtxCanceledEarly(t *testing.T) { + rt, _ := runOneQueryRuntime(t) + // Deliberately register no stub: runOneQuery must short-circuit before + // touching the transport, so the absence of a stub is the assertion. + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got := runOneQuery(ctx, rt, 0, "alice", nil) + if got.ErrMsg != "context canceled" { + t.Errorf("ErrMsg: got %q, want %q", got.ErrMsg, "context canceled") + } + if got.Index != 0 || got.Query != "alice" { + t.Errorf("Index/Query mismatch: %+v", got) + } +} diff --git a/skills/lark-contact/references/lark-contact-search-user.md b/skills/lark-contact/references/lark-contact-search-user.md index a7f692d0..ed346da4 100644 --- a/skills/lark-contact/references/lark-contact-search-user.md +++ b/skills/lark-contact/references/lark-contact-search-user.md @@ -12,13 +12,14 @@ ## 关键 flag -`--query` / `--user-ids` / 4 个 bool filter 至少传一个,否则报错。完整 flag 看 `lark-cli contact +search-user --help`。 +`--query` / `--queries` / `--user-ids` / bool filter 至少传一个。bool filter 显式传 `=false` 会报错——不传等于不过滤。 | Flag | 作用 | |---|---| -| `--query ` | 关键词(姓名 / 邮箱 / 手机号),≤ 64 rune | -| `--user-ids ` | open_id 列表,逗号分隔,≤ 100;支持 `me` 表示自己;与 `--query` 同传时把搜索范围限定在该集合 | -| `--has-chatted` | 仅搜聊过天的(opt-in;**显式 `=false` 会被拒**,下同) | +| `--query ` | 关键词(姓名 / 邮箱 / 手机号),≤ 50 rune | +| `--queries ` | 多个关键词并行搜,**最多 20 条**;与 `--query` / `--user-ids` 互斥;输出新 shape(见下) | +| `--user-ids ` | open_id 列表,≤ 100;支持 `me` 表示自己;与 `--query` 同传时把搜索范围限定在该集合 | +| `--has-chatted` | 仅搜聊过天的 | | `--has-enterprise-email` | 仅搜有企业邮箱的 | | `--exclude-external-users` | 仅搜同租户(排除外部联系人) | | `--left-organization` | 仅搜已离职的 | @@ -47,6 +48,30 @@ lark-cli contact +search-user --query "王" --exclude-external-users --has-enter lark-cli contact +search-user --has-chatted --left-organization ``` +## 批量并行查询 (fanout) + +一次查多个名字: + +```bash +lark-cli contact +search-user --queries "Alice,Bob,张三" +``` + +- 每行 user 带 `matched_query`,标识来自哪个 query +- `queries[]` 每个输入一条 `{query, error?, has_more}`,失败的有 `error` +- 部分失败不影响其它 query;全部失败才 exit 非 0 + +```bash +# bool filter 对每个 query 都生效 +lark-cli contact +search-user --queries "Alice,Bob" --has-chatted + +# 与 --query / --user-ids 互斥 +lark-cli contact +search-user --queries "a" --query "b" # ❌ exit 2 +``` + +约束: +- 最多 20 条; 每条 ≤ 50 字符 +- 重复条目静默去重;全空 csv (`,,,`) 报错 + ## 同名 disambiguation 搜常见姓名常返回多条同名结果。后续操作若有副作用(发消息、邀请会议等),把候选列给用户挑;**不要擅自选**。 @@ -61,28 +86,39 @@ lark-cli contact +search-user --query "张三" \ ## 注意事项 -- **不会自动翻页**。`has_more=true` 表示要 refine query,不是叫你翻页。 -- **bool filter 显式传 `=false` 会报错**:不传等于不过滤;启用就传 flag(不带值)。 +- **不会自动翻页**。`has_more=true` 表示需要 refine query。 - **`--lang` 只影响输出展示名**,不影响匹配字段。 -- **`--query` 与 `--user-ids` 同时设**:`--user-ids` 进 `filter.user_ids`(限定搜索范围),`--query` 进顶层(关键字),按服务端 filter 语义在该 ID 集合内匹配;请求结构可 `--dry-run` 确认。 +- **`--query` 与 `--user-ids` 同时设**:`--user-ids` 限定搜索范围,`--query` 在该集合内匹配。 ## 输出字段 contract -`data.users[]` 的字段集合稳定,可直接 jq / 反序列化。跨租户用户(`is_cross_tenant=true`)按飞书可见性规则,业务字段可能为空字符串 —— 下游做空值兜底,不要当成"字段缺失"。 +跨租户用户(`is_cross_tenant=true`)的业务字段可能为空字符串,需做空值兜底。 | 字段 | 类型 | 说明 | 跨租户 | |---|---|---|---| -| `open_id` | string | 稳定标识,后续命令以此为准 | 始终非空 | -| `localized_name` | string | 按 `--lang` / brand 选出的展示名;想换语言重查时传 `--lang en_us` 等 | 始终非空(兜底为 open_id) | +| `open_id` | string | 稳定标识,后续命令的输入 | 始终非空 | +| `localized_name` | string | 按 `--lang` / brand 选出的展示名 | 始终非空(兜底为 open_id) | | `email` | string | 个人邮箱 | 可能为空 | | `enterprise_email` | string | 企业邮箱 | 可能为空 | | `is_activated` | bool | 是否已激活飞书账号(未激活也可投递消息,但用户可能看不到) | 可能 false | | `is_cross_tenant` | bool | 是否跨租户用户(同公司=false,外部联系人=true) | — | -| `p2p_chat_id` | string | 与当前用户的现有 P2P 会话 ID(`oc_...`);空表示从未私聊过。可作为任何接受 `--chat-id` 的 IM 命令的输入 | 可能为空 | +| `p2p_chat_id` | string | 与当前用户的 P2P 会话 ID(`oc_...`);空表示从未私聊过。可作为接受 `--chat-id` 的 IM 命令的输入 | 可能为空 | | `has_chatted` | bool | `p2p_chat_id != ""` 的派生字段 | — | | `department` | string | 部门路径,服务端可能用 `-` 拼层级,层级数不固定。**按可子串匹配的字符串处理** | 可能为空 | -| `signature` | string | 用户个性签名(API 原名 `description`,本 CLI 重命名以反映真实语义)。同名 disambiguation 时可作为辅助信号 | 可能为空 | -| `chat_recency_hint` | string | 最近联系提示文案,如 `"Contacted 2 days ago"`;空表示无近期联系 | 可能为空 | +| `signature` | string (optional) | 用户个性签名;空时字段不出现 | 可能不出现 | +| `chat_recency_hint` | string | 最近联系的提示文案,仅供展示 | 可能为空 | | `match_segments` | string[] | 关键词命中的字符串片段,用于高亮展示;无命中则为空数组 | — | -表中字段即本 shortcut 的输出契约,移除或改名按 breaking change 处理。 +### `--queries` 模式额外字段 + +`data.users[]` 每条多 `matched_query` (string),指明本行来自哪个 query。 + +`data.queries[]` 按输入顺序、dedup 后每个 query 一条: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `query` | string | 该输入 | +| `error` | string (optional) | 失败原因;成功时不出现 | +| `has_more` | bool | 该 query 还有更多结果 | + +fanout 模式无顶层 `data.has_more`。