feat(contact +search-user): add --queries multi-name fanout (#707)

Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).

Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success

Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.

Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.

All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.

Drive-by: signature field is now omitempty (mostly empty in practice).

Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
  hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
  primary array correctly

Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0
This commit is contained in:
liangshuo-1
2026-04-29 17:03:21 +08:00
committed by GitHub
parent ea056d132e
commit f7a56f38b1
6 changed files with 1173 additions and 39 deletions

View File

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

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

@@ -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 <text>` | 关键词(姓名 / 邮箱 / 手机号),≤ 64 rune |
| `--user-ids <csv>` | open_id 列表,逗号分隔,≤ 100;支持 `me` 表示自己;与 `--query` 同传时把搜索范围限定在该集合 |
| `--has-chatted` | 仅搜聊过天的(opt-in;**显式 `=false` 会被拒**,下同) |
| `--query <text>` | 关键词(姓名 / 邮箱 / 手机号),≤ 50 rune |
| `--queries <csv>` | 多个关键词并行搜,**最多 20 条**;与 `--query` / `--user-ids` 互斥;输出新 shape(见下) |
| `--user-ids <csv>` | 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`