larksuite--cli
bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
318 行
11 KiB
Go
318 行
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package im
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/util"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// ImChatSearch is the +chat-search shortcut: wraps POST /open-apis/im/v2/chats/search
|
|
// to find visible group chats by keyword and/or member open_ids. Supports
|
|
// member/type filters, sort order, pagination, and (user identity only) the
|
|
// --exclude-muted client-side mute filter.
|
|
var ImChatSearch = common.Shortcut{
|
|
Service: "im",
|
|
Command: "+chat-search",
|
|
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
|
|
Risk: "read",
|
|
Scopes: []string{"im:chat:read"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
HasFormat: true,
|
|
Flags: []common.Flag{
|
|
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
|
|
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
|
|
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
|
|
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
|
|
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
|
|
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
|
|
{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
|
|
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
|
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
|
{Name: "page-token", Desc: "pagination token for next page"},
|
|
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
|
|
},
|
|
// DryRun previews the POST /open-apis/im/v2/chats/search request without executing.
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
body := buildSearchChatBody(runtime)
|
|
params := buildSearchChatParams(runtime)
|
|
return common.NewDryRunAPI().
|
|
POST("/open-apis/im/v2/chats/search").
|
|
Params(params).
|
|
Body(body)
|
|
},
|
|
// Validate enforces query/member-ids presence, search-types
|
|
// enum, --member-ids count and format, and --page-size bounds.
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
query := runtime.Str("query")
|
|
memberIDs := runtime.Str("member-ids")
|
|
if query == "" && memberIDs == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
|
|
}
|
|
if st := runtime.Str("search-types"); st != "" {
|
|
allowed := map[string]struct{}{
|
|
"private": {},
|
|
"external": {},
|
|
"public_joined": {},
|
|
"public_not_joined": {},
|
|
}
|
|
for _, item := range common.SplitCSV(st) {
|
|
if _, ok := allowed[item]; !ok {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item).WithParam("--search-types")
|
|
}
|
|
}
|
|
}
|
|
if cm := runtime.Str("chat-modes"); cm != "" {
|
|
for _, mode := range common.SplitCSV(cm) {
|
|
if mode != "group" && mode != "topic" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-modes value %q: expected one of group, topic", mode).WithParam("--chat-modes")
|
|
}
|
|
}
|
|
}
|
|
if mi := runtime.Str("member-ids"); mi != "" {
|
|
ids := common.SplitCSV(mi)
|
|
if len(ids) > 50 {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-ids exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--member-ids")
|
|
}
|
|
for _, id := range ids {
|
|
if _, err := common.ValidateUserIDTyped("--member-ids", id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
|
|
}
|
|
return nil
|
|
},
|
|
// Execute fetches one page, extracts per-item meta_data, optionally applies
|
|
// the --exclude-muted client-side filter (with a PreSkipReason when
|
|
// --search-types is exactly public_not_joined), and renders the result.
|
|
// outData["filter"] is populated only when --exclude-muted is set.
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
body := buildSearchChatBody(runtime)
|
|
params := buildSearchChatParams(runtime)
|
|
resData, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rawItems, _ := resData["items"].([]interface{})
|
|
totalF, _ := util.ToFloat64(resData["total"])
|
|
total := totalF
|
|
hasMore, pageToken := common.PaginationMeta(resData)
|
|
|
|
// Extract MetaData from each item
|
|
var items []map[string]interface{}
|
|
for _, raw := range rawItems {
|
|
item, _ := raw.(map[string]interface{})
|
|
if item == nil {
|
|
continue
|
|
}
|
|
meta, _ := item["meta_data"].(map[string]interface{})
|
|
if meta == nil {
|
|
continue
|
|
}
|
|
items = append(items, meta)
|
|
}
|
|
|
|
preSkipReason := ""
|
|
if runtime.Bool("exclude-muted") {
|
|
preSkipReason = detectAllNonMemberPreSkip(runtime.Str("search-types"))
|
|
}
|
|
mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
|
|
ExcludeMuted: runtime.Bool("exclude-muted"),
|
|
IsBot: runtime.IsBot(),
|
|
PreSkipReason: preSkipReason,
|
|
Chats: items,
|
|
ChatIDKey: "chat_id",
|
|
HasMore: hasMore,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items = mfOut.Chats
|
|
|
|
outData := map[string]interface{}{
|
|
"chats": items,
|
|
"total": int(total),
|
|
"has_more": hasMore,
|
|
"page_token": pageToken,
|
|
}
|
|
if notice, _ := resData["notice"].(string); notice != "" {
|
|
outData["notice"] = notice
|
|
}
|
|
if mfOut.Meta.Applied != "" {
|
|
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
|
|
}
|
|
|
|
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
|
if len(items) == 0 {
|
|
fmt.Fprintln(w, "No matching group chats found.")
|
|
if mfOut.Meta.Hint != "" {
|
|
fmt.Fprintln(w, mfOut.Meta.Hint)
|
|
}
|
|
return
|
|
}
|
|
var rows []map[string]interface{}
|
|
for _, m := range items {
|
|
row := map[string]interface{}{
|
|
"chat_id": m["chat_id"],
|
|
"name": m["name"],
|
|
}
|
|
if desc, _ := m["description"].(string); desc != "" {
|
|
row["description"] = desc
|
|
}
|
|
if ownerID, _ := m["owner_id"].(string); ownerID != "" {
|
|
row["owner_id"] = ownerID
|
|
}
|
|
if chatMode, _ := m["chat_mode"].(string); chatMode != "" {
|
|
row["chat_mode"] = chatMode
|
|
}
|
|
if external, ok := m["external"].(bool); ok {
|
|
row["external"] = external
|
|
}
|
|
if status, _ := m["chat_status"].(string); status != "" {
|
|
row["chat_status"] = status
|
|
}
|
|
if createTime, _ := m["create_time"].(string); createTime != "" {
|
|
row["create_time"] = createTime
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
output.PrintTable(w, rows)
|
|
moreHint := ""
|
|
if hasMore {
|
|
moreHint = " (more available, use --page-token to fetch next page"
|
|
if pageToken != "" {
|
|
moreHint += fmt.Sprintf(", page_token: %s", pageToken)
|
|
}
|
|
moreHint += ")"
|
|
}
|
|
fmt.Fprintf(w, "\n%d chat(s) found%s\n", int(total), moreHint)
|
|
if mfOut.Meta.Hint != "" {
|
|
fmt.Fprintln(w, mfOut.Meta.Hint)
|
|
}
|
|
})
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
|
|
// from the runtime flag values. The query string is normalized via
|
|
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
|
|
// is omitted when no filter flags are set; "sorter" is omitted when --sort
|
|
// (and its hidden alias --sort-by) is unset.
|
|
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
|
|
body := map[string]interface{}{}
|
|
|
|
if query := runtime.Str("query"); query != "" {
|
|
// API behavior: hyphenated keywords should be wrapped in double quotes
|
|
// for more accurate search results.
|
|
body["query"] = normalizeChatSearchQuery(query)
|
|
}
|
|
|
|
// Build filter
|
|
filter := map[string]interface{}{}
|
|
if st := runtime.Str("search-types"); st != "" {
|
|
filter["search_types"] = common.SplitCSV(st)
|
|
}
|
|
// chat_modes is a server-side filter. The CLI exposes group/topic; the wire
|
|
// expects default/thread. Map and dedupe (the API caps the list at 2, and
|
|
// there are only 2 distinct modes) while preserving the user's order.
|
|
if cm := runtime.Str("chat-modes"); cm != "" {
|
|
seen := map[string]bool{}
|
|
var modes []string
|
|
for _, mode := range common.SplitCSV(cm) {
|
|
wire := map[string]string{"group": "default", "topic": "thread"}[mode]
|
|
if wire == "" || seen[wire] {
|
|
continue
|
|
}
|
|
seen[wire] = true
|
|
modes = append(modes, wire)
|
|
}
|
|
if len(modes) > 0 {
|
|
filter["chat_modes"] = modes
|
|
}
|
|
}
|
|
if mi := runtime.Str("member-ids"); mi != "" {
|
|
filter["member_ids"] = common.SplitCSV(mi)
|
|
}
|
|
if runtime.Bool("is-manager") {
|
|
filter["is_manager"] = true
|
|
}
|
|
if runtime.Bool("disable-search-by-user") {
|
|
filter["disable_search_by_user"] = true
|
|
}
|
|
if len(filter) > 0 {
|
|
body["filter"] = filter
|
|
}
|
|
|
|
// Build sorter (always descending). --sort maps field -> field_desc; the hidden
|
|
// --sort-by alias is already the upstream value (pass-through). Omitted when unset.
|
|
sorter := map[string]string{
|
|
"create_time": "create_time_desc",
|
|
"update_time": "update_time_desc",
|
|
"member_count": "member_count_desc",
|
|
}[runtime.Str("sort")]
|
|
if old, ok := aliasFlagValue(runtime, "sort-by", "sort"); ok {
|
|
sorter = old
|
|
}
|
|
if sorter != "" {
|
|
body["sorter"] = sorter
|
|
}
|
|
|
|
return body
|
|
}
|
|
|
|
// buildSearchChatParams builds the query parameters for the POST
|
|
// /im/v2/chats/search call. page_size defaults to the API default of 20 when
|
|
// not provided; page_token is omitted when empty.
|
|
func buildSearchChatParams(runtime *common.RuntimeContext) map[string]interface{} {
|
|
params := map[string]interface{}{}
|
|
if n := runtime.Int("page-size"); n > 0 {
|
|
params["page_size"] = n
|
|
} else {
|
|
params["page_size"] = 20
|
|
}
|
|
if pt := runtime.Str("page-token"); pt != "" {
|
|
params["page_token"] = pt
|
|
}
|
|
return params
|
|
}
|
|
|
|
// normalizeChatSearchQuery wraps hyphenated search queries in double quotes
|
|
// because the search API treats hyphenated keywords specially and expects the
|
|
// whole query to be quoted. Already-quoted input is unwrapped before requoting
|
|
// so we don't emit nested quotes. Inputs without "-" pass through unchanged.
|
|
func normalizeChatSearchQuery(query string) string {
|
|
if !strings.Contains(query, "-") {
|
|
return query
|
|
}
|
|
if unquoted, err := strconv.Unquote(query); err == nil {
|
|
query = unquoted
|
|
}
|
|
return strconv.Quote(query)
|
|
}
|
|
|
|
// detectAllNonMemberPreSkip returns SkipReasonAllNonMember when --search-types
|
|
// is exactly "public_not_joined" — the one combination guaranteeing no member
|
|
// chats, making the mute filter a no-op. Any other value (including empty or
|
|
// mixed) returns "".
|
|
func detectAllNonMemberPreSkip(searchTypesCSV string) string {
|
|
types := common.SplitCSV(searchTypesCSV)
|
|
if len(types) == 1 && types[0] == "public_not_joined" {
|
|
return SkipReasonAllNonMember
|
|
}
|
|
return ""
|
|
}
|