diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index e7be9cdf0..ca78fc3d1 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -674,6 +674,25 @@ func TestShortcutDryRunShapes(t *testing.T) { } }) + t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "team-alpha", + }, map[string]bool{ + "exclude-muted": true, + }) + got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime)) + // Filter is client-side; --exclude-muted must NOT mutate request body or auto-inject search_types. + if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) { + t.Fatalf("ImChatSearch.DryRun() missing endpoint: %s", got) + } + if strings.Contains(got, `"exclude_muted"`) || strings.Contains(got, `"exclude-muted"`) { + t.Fatalf("--exclude-muted leaked into request: %s", got) + } + if strings.Contains(got, `"search_types"`) { + t.Fatalf("search_types must not be auto-injected by --exclude-muted: %s", got) + } + }) + t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) { runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{ "query": "incident", @@ -809,6 +828,20 @@ func TestShortcutDryRunShapes(t *testing.T) { t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted) } }) + + t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-id-type": "open_id", + "sort-type": "ByCreateTimeAsc", + }, nil) + got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/chats"`) { + t.Fatalf("ImChatList.DryRun() = %s", got) + } + if !strings.Contains(got, `"sort_type":"ByCreateTimeAsc"`) { + t.Fatalf("ImChatList.DryRun() missing sort_type: %s", got) + } + }) } func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) { @@ -823,3 +856,26 @@ func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) { t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted) } } + +func TestDetectAllNonMemberPreSkip(t *testing.T) { + cases := []struct { + name string + searchTypes string + want string + }{ + {"empty", "", ""}, + {"only public_not_joined", "public_not_joined", SkipReasonAllNonMember}, + {"public_not_joined with whitespace", " public_not_joined ", SkipReasonAllNonMember}, + {"private only", "private", ""}, + {"mixed includes public_not_joined", "public_not_joined,private", ""}, + {"all four types", "private,public_joined,external,public_not_joined", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := detectAllNonMemberPreSkip(c.searchTypes) + if got != c.want { + t.Fatalf("detectAllNonMemberPreSkip(%q) = %q, want %q", c.searchTypes, got, c.want) + } + }) + } +} diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 853d5e9f0..2b4c4b0da 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -859,6 +859,7 @@ func TestShortcuts(t *testing.T) { want := []string{ "+chat-create", + "+chat-list", "+chat-messages-list", "+chat-search", "+chat-update", diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go new file mode 100644 index 000000000..61b4a2870 --- /dev/null +++ b/shortcuts/im/im_chat_list.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// imChatListPath is the upstream HTTP path for the +chat-list shortcut. +const imChatListPath = "/open-apis/im/v1/chats" + +// ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to +// list groups the current user/bot is a member of. Supports sort order, +// pagination, and (user identity only) muted-chat filtering via --exclude-muted. +var ImChatList = common.Shortcut{ + Service: "im", + Command: "+chat-list", + Description: "List groups the current user/bot is a member of; user/bot; supports 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: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}}, + {Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}}, + {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 GET /open-apis/im/v1/chats request without executing. + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET(imChatListPath). + Params(buildChatListParams(runtime)) + }, + // Validate enforces flag preconditions; only --page-size has bounds (1-100). + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if n := runtime.Int("page-size"); n < 1 || n > 100 { + return output.ErrValidation("--page-size must be an integer between 1 and 100") + } + return nil + }, + // Execute fetches one page of chats, optionally applies --exclude-muted + // via MaybeApplyMuteFilter, and renders the result. outData["filter"] is + // populated only when --exclude-muted is set (backward compatible). + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + params := buildChatListParams(runtime) + resData, err := runtime.CallAPI("GET", imChatListPath, params, nil) + if err != nil { + return err + } + + rawItems, _ := resData["items"].([]interface{}) + hasMore, pageToken := common.PaginationMeta(resData) + + var items []map[string]interface{} + for _, raw := range rawItems { + item, _ := raw.(map[string]interface{}) + if item == nil { + continue + } + items = append(items, item) + } + + mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{ + ExcludeMuted: runtime.Bool("exclude-muted"), + IsBot: runtime.IsBot(), + Chats: items, + ChatIDKey: "chat_id", + HasMore: hasMore, + }) + if err != nil { + return err + } + items = mfOut.Chats + + outData := map[string]interface{}{ + "chats": items, + "has_more": hasMore, + "page_token": pageToken, + } + 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 chats found.") + if mfOut.Meta.Hint != "" { + fmt.Fprintln(w, mfOut.Meta.Hint) + } + return + } + rows := make([]map[string]interface{}, 0, len(items)) + 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 external, ok := m["external"].(bool); ok { + row["external"] = external + } + if status, _ := m["chat_status"].(string); status != "" { + row["chat_status"] = status + } + rows = append(rows, row) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d chat(s) listed", len(rows)) + if hasMore { + fmt.Fprint(w, " (more available, use --page-token to fetch next page") + if pageToken != "" { + fmt.Fprintf(w, ", page_token: %s", pageToken) + } + fmt.Fprint(w, ")") + } + fmt.Fprintln(w) + if mfOut.Meta.Hint != "" { + fmt.Fprintln(w, mfOut.Meta.Hint) + } + }) + return nil + }, +} + +// buildChatListParams builds the query parameters for the GET /im/v1/chats +// call from the runtime flag values. user_id_type and sort_type are always +// present (their flag defaults are non-empty); page_token is omitted when +// empty; page_size falls back to the API default of 20 when not provided. +func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "user_id_type": runtime.Str("user-id-type"), + "sort_type": runtime.Str("sort-type"), + } + 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 +} diff --git a/shortcuts/im/im_chat_list_test.go b/shortcuts/im/im_chat_list_test.go new file mode 100644 index 000000000..a99348262 --- /dev/null +++ b/shortcuts/im/im_chat_list_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +// newChatListTestRuntimeContext mirrors newMessagesSearchTestRuntimeContext — +// it registers page-size as Int (the existing newTestRuntimeContext registers +// it as String, which would short-circuit our buildChatListParams logic). +func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 20, "") + for name := range stringFlags { + if name == "page-size" { + continue + } + cmd.Flags().String(name, "", "") + } + for name := range boolFlags { + cmd.Flags().Bool(name, false, "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, val := range stringFlags { + if err := cmd.Flags().Set(name, val); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, val := range boolFlags { + if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +func TestBuildChatListParams_Defaults(t *testing.T) { + rt := newChatListTestRuntimeContext(t, map[string]string{ + "user-id-type": "open_id", + "sort-type": "ByCreateTimeAsc", + }, nil) + got := buildChatListParams(rt) + if got["user_id_type"] != "open_id" { + t.Fatalf("user_id_type = %v", got["user_id_type"]) + } + if got["sort_type"] != "ByCreateTimeAsc" { + t.Fatalf("sort_type = %v", got["sort_type"]) + } + if got["page_size"] != 20 { + t.Fatalf("page_size = %v, want 20", got["page_size"]) + } + if _, present := got["page_token"]; present { + t.Fatalf("page_token should be omitted when empty") + } +} + +func TestBuildChatListParams_Overrides(t *testing.T) { + rt := newChatListTestRuntimeContext(t, map[string]string{ + "user-id-type": "user_id", + "sort-type": "ByActiveTimeDesc", + "page-size": "50", + "page-token": "tok_xyz", + }, nil) + got := buildChatListParams(rt) + if got["user_id_type"] != "user_id" { + t.Fatalf("user_id_type = %v", got["user_id_type"]) + } + if got["sort_type"] != "ByActiveTimeDesc" { + t.Fatalf("sort_type = %v", got["sort_type"]) + } + if got["page_size"] != 50 { + t.Fatalf("page_size = %v, want 50", got["page_size"]) + } + if got["page_token"] != "tok_xyz" { + t.Fatalf("page_token = %v", got["page_token"]) + } +} + +func TestImChatList_Validate_PageSizeBounds(t *testing.T) { + cases := []struct { + name string + pageSize string + wantErr bool + }{ + {"zero rejected", "0", true}, + {"negative rejected", "-1", true}, + {"one ok", "1", false}, + {"hundred ok", "100", false}, + {"oneoone rejected", "101", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rt := newChatListTestRuntimeContext(t, map[string]string{"page-size": c.pageSize}, nil) + err := ImChatList.Validate(context.Background(), rt) + if (err != nil) != c.wantErr { + t.Fatalf("Validate() err = %v, wantErr=%v", err, c.wantErr) + } + }) + } +} + +func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) { + rt := newChatListTestRuntimeContext(t, map[string]string{ + "user-id-type": "open_id", + "sort-type": "ByActiveTimeDesc", + "page-size": "30", + }, nil) + got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"/open-apis/im/v1/chats"`) { + t.Fatalf("DryRun missing endpoint: %s", got) + } + if !strings.Contains(got, `"sort_type":"ByActiveTimeDesc"`) { + t.Fatalf("DryRun missing sort_type: %s", got) + } + if !strings.Contains(got, `"page_size":30`) { + t.Fatalf("DryRun missing page_size: %s", got) + } +} diff --git a/shortcuts/im/im_chat_search.go b/shortcuts/im/im_chat_search.go index febb7ebb8..bcb56afd1 100644 --- a/shortcuts/im/im_chat_search.go +++ b/shortcuts/im/im_chat_search.go @@ -15,10 +15,14 @@ import ( "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 keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination", + 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"}, @@ -32,7 +36,9 @@ var ImChatSearch = common.Shortcut{ {Name: "sort-by", Desc: "sort field (descending)", 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) @@ -41,6 +47,8 @@ var ImChatSearch = common.Shortcut{ Params(params). Body(body) }, + // Validate enforces query/member-ids presence, --query rune cap, 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") @@ -79,6 +87,10 @@ var ImChatSearch = common.Shortcut{ } 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) @@ -106,16 +118,39 @@ var ImChatSearch = common.Shortcut{ 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 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{} @@ -154,11 +189,19 @@ var ImChatSearch = common.Shortcut{ 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-by +// is empty. func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} { body := map[string]interface{}{} @@ -194,6 +237,9 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} 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 { @@ -207,10 +253,11 @@ func buildSearchChatParams(runtime *common.RuntimeContext) map[string]interface{ 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 { - // The search API treats hyphenated keywords specially and expects the whole - // query to be quoted. Normalize already-quoted input before requoting so we - // don't emit nested quotes. if !strings.Contains(query, "-") { return query } @@ -219,3 +266,15 @@ func normalizeChatSearchQuery(query string) string { } 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 "" +} diff --git a/shortcuts/im/mute_filter.go b/shortcuts/im/mute_filter.go new file mode 100644 index 000000000..da5d08b03 --- /dev/null +++ b/shortcuts/im/mute_filter.go @@ -0,0 +1,320 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package-level helper: client-side filter that drops muted chats from +// search/list results by calling /open-apis/im/v1/chat_user_setting/batch_get_mute_status. +// +// The native chat search/list APIs do not return mute status; we fetch it as +// a separate batch lookup, then drop is_muted=true items. Non-member / +// invalid-format chat_ids come back via invalid_id_list and are silently +// retained (we don't know their mute state). Bot identity is unsupported by +// the upstream API (UAT-only), so we skip the filter and emit a machine-readable +// skipped indicator instead of erroring. + +package im + +import ( + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// MuteFilterMeta describes the outcome of a single page's mute filter run. +// UnknownCount is internal — used to compose the hint, not exposed in JSON. +type MuteFilterMeta struct { + Applied string + Skipped bool + SkipReason string + FetchedCount int + ReturnedCount int + FilteredCount int + UnknownCount int + Hint string +} + +// MaxMuteStatusBatchSize is the upstream cap for chat_ids per +// batch_get_mute_status call (after dedupe). +const MaxMuteStatusBatchSize = 100 + +// BatchGetMuteStatusPath is the upstream HTTP path. +const BatchGetMuteStatusPath = "/open-apis/im/v1/chat_user_setting/batch_get_mute_status" + +// SkipReason constants — written to filter.skip_reason when Skipped=true. +const ( + SkipReasonBotIdentity = "bot_identity_no_mute_data" + SkipReasonAllNonMember = "all_non_member_search_types" +) + +// BuildMuteFilterHint composes the user/AI-facing English hint for a finished +// filter run. hasMore is the underlying API's has_more (so we can suggest paging). +// Returns "" when the filter ran but had no effect (FilteredCount==0 and not skipped). +func BuildMuteFilterHint(meta MuteFilterMeta, hasMore bool) string { + if meta.Skipped { + switch meta.SkipReason { + case SkipReasonBotIdentity: + return "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter." + case SkipReasonAllNonMember: + if hasMore { + return "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more." + } + return "All results on this page are non-member public groups; mute filter does not apply. No more pages." + } + return "" + } + if meta.FilteredCount == 0 { + return "" + } + + tail := "no more pages." + if hasMore { + tail = "use --page-token to fetch more." + } + + if meta.UnknownCount > 0 { + return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining, including %d non-member public group(s)); %s", + meta.FilteredCount, meta.ReturnedCount, meta.UnknownCount, tail) + } + return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining); %s", + meta.FilteredCount, meta.ReturnedCount, tail) +} + +// BuildBatchGetMuteStatusBody constructs the request body for +// POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status. +func BuildBatchGetMuteStatusBody(chatIDs []string) map[string]interface{} { + return map[string]interface{}{"chat_ids": chatIDs} +} + +// ParseBatchGetMuteStatusResponse maps the API response to: +// - muted: chat_id -> is_muted, only for ids returned in items +// - unknown: chat_ids that came back in invalid_id_list (any msg) OR +// were in input but missing from both lists. +// +// unknown preserves input order for stable hint output. +func ParseBatchGetMuteStatusResponse(input []string, resp map[string]interface{}) (map[string]bool, []string) { + muted := make(map[string]bool, len(input)) + if rawItems, ok := resp["items"].([]interface{}); ok { + for _, raw := range rawItems { + item, _ := raw.(map[string]interface{}) + if item == nil { + continue + } + cid, _ := item["chat_id"].(string) + if cid == "" { + continue + } + isMuted, _ := item["is_muted"].(bool) + muted[cid] = isMuted + } + } + + unknownSet := make(map[string]struct{}) + if rawInvalid, ok := resp["invalid_id_list"].([]interface{}); ok { + for _, raw := range rawInvalid { + item, _ := raw.(map[string]interface{}) + if item == nil { + continue + } + id, _ := item["id"].(string) + if id != "" { + unknownSet[id] = struct{}{} + } + } + } + for _, id := range input { + if _, hasMute := muted[id]; hasMute { + continue + } + unknownSet[id] = struct{}{} + } + + unknown := make([]string, 0, len(unknownSet)) + for _, id := range input { + if _, ok := unknownSet[id]; ok { + unknown = append(unknown, id) + delete(unknownSet, id) // dedupe while preserving input order + } + } + return muted, unknown +} + +// ApplyMuteFilter drops chats whose mute map entry is true. Chats whose id +// is in the unknown set, or which have no chatIDKey value, are retained +// (we have no basis to filter them) and counted as UnknownCount. +// +// Pure function; no API calls. The caller is responsible for fetching the +// mute map via FetchMuteStatus. +// +// Invariant: meta.FetchedCount == meta.ReturnedCount + meta.FilteredCount. +func ApplyMuteFilter( + chats []map[string]interface{}, + chatIDKey string, + muted map[string]bool, + unknown []string, +) ([]map[string]interface{}, MuteFilterMeta) { + unknownSet := make(map[string]struct{}, len(unknown)) + for _, id := range unknown { + unknownSet[id] = struct{}{} + } + + out := make([]map[string]interface{}, 0, len(chats)) + meta := MuteFilterMeta{Applied: "exclude_muted", FetchedCount: len(chats)} + + for _, row := range chats { + cid, _ := row[chatIDKey].(string) + if cid == "" { + out = append(out, row) + meta.UnknownCount++ + continue + } + if _, isUnknown := unknownSet[cid]; isUnknown { + out = append(out, row) + meta.UnknownCount++ + continue + } + if isMuted, ok := muted[cid]; ok { + if isMuted { + meta.FilteredCount++ + continue + } + out = append(out, row) + continue + } + // Defensive: id not in muted, not in unknown — treat as unknown, retain. + out = append(out, row) + meta.UnknownCount++ + } + meta.ReturnedCount = len(out) + return out, meta +} + +// ExtractChatIDs collects unique chat_ids (in input order) from a page of rows. +// Rows missing the key or with an empty value are skipped. +func ExtractChatIDs(chats []map[string]interface{}, chatIDKey string) []string { + if len(chats) == 0 { + return nil + } + seen := make(map[string]struct{}, len(chats)) + out := make([]string, 0, len(chats)) + for _, row := range chats { + cid, _ := row[chatIDKey].(string) + if cid == "" { + continue + } + if _, dup := seen[cid]; dup { + continue + } + seen[cid] = struct{}{} + out = append(out, cid) + } + return out +} + +// MuteFilterMetaToMap renders the meta as the "filter" sub-object the +// command writes into outData. The schema is fixed-shape: exactly 5 fields, +// regardless of skip state. +// +// Skip context (bot identity / all-non-member search-types) is encoded +// entirely in the Hint string — consumers read the natural-language hint +// to understand why the filter did or did not apply. UnknownCount and the +// Skipped / SkipReason struct fields are internal-only (used to compose +// Hint) and are not exposed in JSON. +func MuteFilterMetaToMap(meta MuteFilterMeta) map[string]interface{} { + return map[string]interface{}{ + "applied": meta.Applied, + "fetched_count": meta.FetchedCount, + "returned_count": meta.ReturnedCount, + "filtered_count": meta.FilteredCount, + "hint": meta.Hint, + } +} + +// FetchMuteStatus calls batch_get_mute_status for the given chat_ids and +// parses the result. Caller MUST ensure len(chatIDs) <= MaxMuteStatusBatchSize +// (the shortcuts already cap --page-size at 100, so a single page is safe). +// +// Empty input is a no-op (avoids triggering the upstream "chat_ids is empty" +// InvalidParam). +func FetchMuteStatus(runtime *common.RuntimeContext, chatIDs []string) (map[string]bool, []string, error) { + if len(chatIDs) == 0 { + return map[string]bool{}, nil, nil + } + if len(chatIDs) > MaxMuteStatusBatchSize { + return nil, nil, output.ErrValidation( + "batch_get_mute_status accepts at most %d chat_ids per call (got %d)", + MaxMuteStatusBatchSize, len(chatIDs)) + } + body := BuildBatchGetMuteStatusBody(chatIDs) + resp, err := runtime.CallAPI("POST", BatchGetMuteStatusPath, nil, body) + if err != nil { + return nil, nil, fmt.Errorf("fetch mute status: %w", err) + } + muted, unknown := ParseBatchGetMuteStatusResponse(chatIDs, resp) + return muted, unknown, nil +} + +// MuteFilterInput captures everything the orchestrator needs from the calling shortcut. +type MuteFilterInput struct { + ExcludeMuted bool // value of --exclude-muted + IsBot bool // current identity + PreSkipReason string // optional caller-supplied skip reason (e.g. SkipReasonAllNonMember); leave empty under bot — IsBot is handled separately + Chats []map[string]interface{} // page of result rows + ChatIDKey string // key in row holding the chat_id ("chat_id" for both v1 list and v2 search meta_data) + HasMore bool // for hint composition +} + +// MuteFilterOutput is what the shortcut writes back into outData. +type MuteFilterOutput struct { + Chats []map[string]interface{} // filtered (or unchanged when not applied) + Meta MuteFilterMeta // zero-valued when ExcludeMuted=false; callers detect via Meta.Applied != "" +} + +// MaybeApplyMuteFilter is the single entry point shortcuts call. +// +// Behavior: +// - ExcludeMuted=false: returns chats unchanged, Meta is zero-valued (Applied=="") +// - ExcludeMuted=true && IsBot: skip the API call, mark Skipped with SkipReasonBotIdentity +// - ExcludeMuted=true && PreSkipReason!="" (not bot): skip the API call, mark Skipped with that reason +// - ExcludeMuted=true && len(chats)==0: skip the API call (avoids upstream +// InvalidParam on empty chat_ids); meta has zero counts, Skipped=false +// - ExcludeMuted=true && otherwise: fetch + apply; populate counts and Hint +// +// Callers detect whether the filter ran via out.Meta.Applied != "". +// Callers compose the JSON map via MuteFilterMetaToMap(out.Meta) at the use site. +func MaybeApplyMuteFilter(runtime *common.RuntimeContext, in MuteFilterInput) (MuteFilterOutput, error) { + if !in.ExcludeMuted { + return MuteFilterOutput{Chats: in.Chats}, nil + } + + meta := MuteFilterMeta{ + Applied: "exclude_muted", + FetchedCount: len(in.Chats), + ReturnedCount: len(in.Chats), + } + + switch { + case in.IsBot: + meta.Skipped = true + meta.SkipReason = SkipReasonBotIdentity + case in.PreSkipReason != "": + meta.Skipped = true + meta.SkipReason = in.PreSkipReason + case len(in.Chats) == 0: + // counts already zero; Skipped stays false + default: + ids := ExtractChatIDs(in.Chats, in.ChatIDKey) + muted, unknown, err := FetchMuteStatus(runtime, ids) + if err != nil { + return MuteFilterOutput{}, err + } + var filtered []map[string]interface{} + filtered, meta = ApplyMuteFilter(in.Chats, in.ChatIDKey, muted, unknown) + in.Chats = filtered + } + + meta.Hint = BuildMuteFilterHint(meta, in.HasMore) + return MuteFilterOutput{ + Chats: in.Chats, + Meta: meta, + }, nil +} diff --git a/shortcuts/im/mute_filter_test.go b/shortcuts/im/mute_filter_test.go new file mode 100644 index 000000000..3f3192b30 --- /dev/null +++ b/shortcuts/im/mute_filter_test.go @@ -0,0 +1,445 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "fmt" + "reflect" + "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestBuildMuteFilterHint(t *testing.T) { + cases := []struct { + name string + meta MuteFilterMeta + hasMore bool + want string + }{ + { + name: "1 skipped bot identity", + meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonBotIdentity}, + hasMore: false, + want: "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter.", + }, + { + name: "2 skipped all non-member, has_more", + meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember}, + hasMore: true, + want: "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more.", + }, + { + name: "3 skipped all non-member, no more", + meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember}, + hasMore: false, + want: "All results on this page are non-member public groups; mute filter does not apply. No more pages.", + }, + { + name: "4 filtered>0 unknown=0 has_more", + meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3}, + hasMore: true, + want: "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more.", + }, + { + name: "5 filtered>0 unknown=0 no more", + meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3}, + hasMore: false, + want: "Filtered out 3 muted chat(s) on this page (17 remaining); no more pages.", + }, + { + name: "6 filtered>0 unknown>0 has_more", + meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2}, + hasMore: true, + want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more.", + }, + { + name: "7 filtered>0 unknown>0 no more", + meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2}, + hasMore: false, + want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); no more pages.", + }, + { + name: "8 filtered=0 returns empty regardless of unknown/hasMore", + meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 5, ReturnedCount: 5, UnknownCount: 2}, + hasMore: true, + want: "", + }, + { + name: "9 skipped with unrecognized reason returns empty", + meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: "unknown_reason"}, + hasMore: false, + want: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := BuildMuteFilterHint(c.meta, c.hasMore) + if got != c.want { + t.Fatalf("BuildMuteFilterHint() = %q, want %q", got, c.want) + } + }) + } +} + +func TestBuildBatchGetMuteStatusBody(t *testing.T) { + got := BuildBatchGetMuteStatusBody([]string{"oc_a", "oc_b"}) + want := map[string]interface{}{"chat_ids": []string{"oc_a", "oc_b"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("BuildBatchGetMuteStatusBody() = %v, want %v", got, want) + } +} + +func TestParseBatchGetMuteStatusResponse(t *testing.T) { + t.Run("happy path with mixed muted/non-muted/invalid", func(t *testing.T) { + input := []string{"oc_a", "oc_b", "oc_c", "bad"} + resp := map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"chat_id": "oc_a", "is_muted": true}, + map[string]interface{}{"chat_id": "oc_b", "is_muted": false}, + }, + "invalid_id_list": []interface{}{ + map[string]interface{}{"id": "oc_c", "msg": "not_a_member"}, + map[string]interface{}{"id": "bad", "msg": "invalid_format"}, + }, + } + muted, unknown := ParseBatchGetMuteStatusResponse(input, resp) + wantMuted := map[string]bool{"oc_a": true, "oc_b": false} + wantUnknown := []string{"oc_c", "bad"} + if !reflect.DeepEqual(muted, wantMuted) { + t.Fatalf("muted = %v, want %v", muted, wantMuted) + } + if !reflect.DeepEqual(unknown, wantUnknown) { + t.Fatalf("unknown = %v, want %v", unknown, wantUnknown) + } + }) + + t.Run("missing chat_ids fall through to unknown", func(t *testing.T) { + input := []string{"oc_a", "oc_b"} + resp := map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"chat_id": "oc_a", "is_muted": true}, + }, + } + muted, unknown := ParseBatchGetMuteStatusResponse(input, resp) + if !reflect.DeepEqual(muted, map[string]bool{"oc_a": true}) { + t.Fatalf("muted = %v", muted) + } + if !reflect.DeepEqual(unknown, []string{"oc_b"}) { + t.Fatalf("unknown = %v", unknown) + } + }) + + t.Run("empty response yields all unknown", func(t *testing.T) { + input := []string{"oc_a"} + muted, unknown := ParseBatchGetMuteStatusResponse(input, map[string]interface{}{}) + if len(muted) != 0 { + t.Fatalf("muted = %v, want empty", muted) + } + if !reflect.DeepEqual(unknown, []string{"oc_a"}) { + t.Fatalf("unknown = %v", unknown) + } + }) + + t.Run("skips nil entries and empty chat_id in items/invalid_id_list", func(t *testing.T) { + input := []string{"oc_a", "oc_b"} + resp := map[string]interface{}{ + "items": []interface{}{ + nil, + map[string]interface{}{"chat_id": "", "is_muted": false}, + map[string]interface{}{"chat_id": "oc_a", "is_muted": true}, + }, + "invalid_id_list": []interface{}{ + nil, + map[string]interface{}{"id": "oc_b", "msg": "not_a_member"}, + }, + } + muted, unknown := ParseBatchGetMuteStatusResponse(input, resp) + if !reflect.DeepEqual(muted, map[string]bool{"oc_a": true}) { + t.Fatalf("muted = %v", muted) + } + if !reflect.DeepEqual(unknown, []string{"oc_b"}) { + t.Fatalf("unknown = %v", unknown) + } + }) +} + +func TestApplyMuteFilter(t *testing.T) { + chats := []map[string]interface{}{ + {"chat_id": "oc_a", "name": "alpha"}, + {"chat_id": "oc_b", "name": "beta"}, + {"chat_id": "oc_c", "name": "gamma"}, + {"chat_id": "oc_d", "name": "delta"}, + } + + t.Run("drops only is_muted=true", func(t *testing.T) { + muted := map[string]bool{"oc_a": true, "oc_b": false, "oc_c": true, "oc_d": false} + got, meta := ApplyMuteFilter(chats, "chat_id", muted, nil) + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if got[0]["chat_id"] != "oc_b" || got[1]["chat_id"] != "oc_d" { + t.Fatalf("got = %v, want [oc_b, oc_d]", got) + } + want := MuteFilterMeta{ + Applied: "exclude_muted", FetchedCount: 4, ReturnedCount: 2, FilteredCount: 2, UnknownCount: 0, + } + if meta != want { + t.Fatalf("meta = %+v, want %+v", meta, want) + } + }) + + t.Run("retains unknown chats and counts them", func(t *testing.T) { + muted := map[string]bool{"oc_a": true, "oc_b": false} + unknown := []string{"oc_c", "oc_d"} + got, meta := ApplyMuteFilter(chats, "chat_id", muted, unknown) + if len(got) != 3 { + t.Fatalf("len(got) = %d, want 3 (oc_b + oc_c + oc_d)", len(got)) + } + if meta.FilteredCount != 1 || meta.UnknownCount != 2 || meta.ReturnedCount != 3 { + t.Fatalf("meta = %+v, want filtered=1 unknown=2 returned=3", meta) + } + }) + + t.Run("preserves original order", func(t *testing.T) { + muted := map[string]bool{"oc_b": true} + got, _ := ApplyMuteFilter(chats, "chat_id", muted, []string{"oc_c", "oc_d"}) + gotIDs := []string{} + for _, r := range got { + gotIDs = append(gotIDs, r["chat_id"].(string)) + } + want := []string{"oc_a", "oc_c", "oc_d"} + if !reflect.DeepEqual(gotIDs, want) { + t.Fatalf("ordering = %v, want %v", gotIDs, want) + } + }) + + t.Run("missing chatIDKey treated as unknown but kept", func(t *testing.T) { + bad := []map[string]interface{}{{"name": "no_id"}} + got, meta := ApplyMuteFilter(bad, "chat_id", map[string]bool{}, nil) + if len(got) != 1 { + t.Fatalf("missing-id row should be retained, got len = %d", len(got)) + } + if meta.UnknownCount != 1 || meta.FilteredCount != 0 || meta.ReturnedCount != 1 { + t.Fatalf("meta = %+v, want unknown=1 filtered=0 returned=1", meta) + } + }) + + t.Run("invariant fetched == returned + filtered", func(t *testing.T) { + muted := map[string]bool{"oc_a": true, "oc_b": false} + _, meta := ApplyMuteFilter(chats, "chat_id", muted, []string{"oc_c", "oc_d"}) + if meta.FetchedCount != meta.ReturnedCount+meta.FilteredCount { + t.Fatalf("invariant broken: fetched=%d, returned=%d, filtered=%d", + meta.FetchedCount, meta.ReturnedCount, meta.FilteredCount) + } + }) +} + +func TestExtractChatIDs(t *testing.T) { + t.Run("dedupes and preserves order", func(t *testing.T) { + chats := []map[string]interface{}{ + {"chat_id": "oc_a"}, + {"chat_id": "oc_b"}, + {"chat_id": "oc_a"}, + {"chat_id": ""}, + {"name": "no_id"}, + {"chat_id": "oc_c"}, + } + got := ExtractChatIDs(chats, "chat_id") + want := []string{"oc_a", "oc_b", "oc_c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ExtractChatIDs() = %v, want %v", got, want) + } + }) + + t.Run("empty input yields empty slice", func(t *testing.T) { + got := ExtractChatIDs(nil, "chat_id") + if len(got) != 0 { + t.Fatalf("ExtractChatIDs(nil) = %v, want empty", got) + } + }) +} + +func TestMuteFilterMetaToMap(t *testing.T) { + wantKeys := []string{"applied", "fetched_count", "returned_count", "filtered_count", "hint"} + + t.Run("active filter exposes exactly 5 fields", func(t *testing.T) { + meta := MuteFilterMeta{ + Applied: "exclude_muted", + FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2, + Hint: "test hint", + } + got := MuteFilterMetaToMap(meta) + if got["applied"] != "exclude_muted" || + got["fetched_count"] != 20 || got["returned_count"] != 19 || + got["filtered_count"] != 1 || got["hint"] != "test hint" { + t.Fatalf("MuteFilterMetaToMap() = %v", got) + } + assertExactKeys(t, got, wantKeys) + }) + + t.Run("skipped path: hint carries the skip explanation, no extra fields", func(t *testing.T) { + meta := MuteFilterMeta{ + Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonBotIdentity, + FetchedCount: 5, ReturnedCount: 5, Hint: "skipped hint", + } + got := MuteFilterMetaToMap(meta) + if got["hint"] != "skipped hint" { + t.Fatalf("hint = %v, want \"skipped hint\"", got["hint"]) + } + assertExactKeys(t, got, wantKeys) + }) +} + +// assertExactKeys fails the test if got has any keys outside want, or is missing any. +func assertExactKeys(t *testing.T, got map[string]interface{}, want []string) { + t.Helper() + wantSet := make(map[string]struct{}, len(want)) + for _, k := range want { + wantSet[k] = struct{}{} + if _, ok := got[k]; !ok { + t.Errorf("missing required key %q", k) + } + } + for k := range got { + if _, ok := wantSet[k]; !ok { + t.Errorf("unexpected key %q in MuteFilterMetaToMap output (got %v)", k, got) + } + } +} + +// runtimeForOrchestrator builds a minimal RuntimeContext for testing the +// branches of MaybeApplyMuteFilter that do NOT call the underlying API. +func runtimeForOrchestrator(t *testing.T) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + return &common.RuntimeContext{Cmd: cmd} +} + +func TestMaybeApplyMuteFilter_NotEnabled(t *testing.T) { + rt := runtimeForOrchestrator(t) + chats := []map[string]interface{}{{"chat_id": "oc_a"}} + out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{ + ExcludeMuted: false, + Chats: chats, + ChatIDKey: "chat_id", + }) + if err != nil { + t.Fatalf("err = %v", err) + } + if len(out.Chats) != 1 || out.Meta.Applied != "" { + t.Fatalf("expected pass-through, got chats=%v meta.applied=%q", out.Chats, out.Meta.Applied) + } +} + +func TestMaybeApplyMuteFilter_BotIdentity(t *testing.T) { + rt := runtimeForOrchestrator(t) + chats := []map[string]interface{}{ + {"chat_id": "oc_a"}, + {"chat_id": "oc_b"}, + } + out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{ + ExcludeMuted: true, + IsBot: true, + Chats: chats, + ChatIDKey: "chat_id", + HasMore: false, + }) + if err != nil { + t.Fatalf("err = %v", err) + } + if len(out.Chats) != 2 { + t.Fatalf("bot skip should retain all chats, got %d", len(out.Chats)) + } + if !out.Meta.Skipped { + t.Fatalf("skipped should be true, got meta=%+v", out.Meta) + } + if out.Meta.SkipReason != SkipReasonBotIdentity { + t.Fatalf("skip_reason = %v", out.Meta.SkipReason) + } + wantHint := "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter." + if out.Meta.Hint != wantHint { + t.Fatalf("hint = %q", out.Meta.Hint) + } +} + +func TestMaybeApplyMuteFilter_PreSkipAllNonMember(t *testing.T) { + rt := runtimeForOrchestrator(t) + chats := []map[string]interface{}{ + {"chat_id": "oc_a"}, + {"chat_id": "oc_b"}, + } + out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{ + ExcludeMuted: true, + IsBot: false, + PreSkipReason: SkipReasonAllNonMember, + Chats: chats, + ChatIDKey: "chat_id", + HasMore: true, + }) + if err != nil { + t.Fatalf("err = %v", err) + } + if len(out.Chats) != 2 { + t.Fatalf("pre-skip should retain all chats, got %d", len(out.Chats)) + } + if !out.Meta.Skipped || out.Meta.SkipReason != SkipReasonAllNonMember { + t.Fatalf("meta = %+v", out.Meta) + } + wantHint := "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more." + if out.Meta.Hint != wantHint { + t.Fatalf("hint = %q", out.Meta.Hint) + } +} + +func TestMaybeApplyMuteFilter_EmptyPage(t *testing.T) { + rt := runtimeForOrchestrator(t) + out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{ + ExcludeMuted: true, + Chats: nil, + ChatIDKey: "chat_id", + }) + if err != nil { + t.Fatalf("err = %v", err) + } + if len(out.Chats) != 0 { + t.Fatalf("expected empty out, got %v", out.Chats) + } + if out.Meta.Applied != "exclude_muted" { + t.Fatalf("meta.applied = %q, want exclude_muted", out.Meta.Applied) + } + if out.Meta.FetchedCount != 0 || out.Meta.ReturnedCount != 0 || out.Meta.FilteredCount != 0 { + t.Fatalf("counts should all be zero, got meta=%+v", out.Meta) + } + if out.Meta.Skipped { + t.Fatalf("empty page is not 'skipped', got meta.skipped=%v", out.Meta.Skipped) + } +} + +func TestFetchMuteStatus_OverLimit(t *testing.T) { + rt := runtimeForOrchestrator(t) + ids := make([]string, MaxMuteStatusBatchSize+1) + for i := range ids { + ids[i] = fmt.Sprintf("oc_%d", i) + } + _, _, err := FetchMuteStatus(rt, ids) + if err == nil { + t.Fatalf("expected error on over-limit batch") + } +} + +func TestFetchMuteStatus_Empty(t *testing.T) { + rt := runtimeForOrchestrator(t) + muted, unknown, err := FetchMuteStatus(rt, nil) + if err != nil { + t.Fatalf("err = %v", err) + } + if len(muted) != 0 || len(unknown) != 0 { + t.Fatalf("expected empty results, got muted=%v unknown=%v", muted, unknown) + } +} diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index 7422da7e0..3c8aadfbe 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common" func Shortcuts() []common.Shortcut { return []common.Shortcut{ ImChatCreate, + ImChatList, ImChatMessageList, ImChatSearch, ImChatUpdate, diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 1666f527d..0d127b244 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -69,8 +69,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 | Shortcut | 说明 | |----------|------| | [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager | +| [`+chat-list`](references/lark-im-chat-list.md) | List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only) | | [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination | -| [`+chat-search`](references/lark-im-chat-search.md) | 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, and pagination | +| [`+chat-search`](references/lark-im-chat-search.md) | 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) | | [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description | | [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies | | [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key | @@ -96,7 +97,6 @@ lark-cli im [flags] # 调用 API - `create` — 创建群。Identity: `bot` only (`tenant_access_token`). - `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats. - `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats. - - `list` — 获取用户或机器人所在的群列表。Identity: supports `user` and `bot`. - `update` — 更新群信息。Identity: supports `user` and `bot`. ### chat.members @@ -141,7 +141,6 @@ lark-cli im [flags] # 调用 API | `chats.create` | `im:chat:create` | | `chats.get` | `im:chat:read` | | `chats.link` | `im:chat:read` | -| `chats.list` | `im:chat:read` | | `chats.update` | `im:chat:update` | | `chat.members.bots` | `im:chat.members:read` | | `chat.members.create` | `im:chat.members:write_only` | diff --git a/skills/lark-im/references/lark-im-chat-list.md b/skills/lark-im/references/lark-im-chat-list.md new file mode 100644 index 000000000..ef6ca06ca --- /dev/null +++ b/skills/lark-im/references/lark-im-chat-list.md @@ -0,0 +1,113 @@ +# im +chat-list + +> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. + +List groups the current user (or bot, with `--as bot`) is a member of. Useful for enumerating "my chats" without a search keyword, or for bulk operations against the caller's chats. Supports pagination, sort order, and (user identity only) muted-chat filtering. + +This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET /open-apis/im/v1/chats`). + +## Commands + +```bash +# List the user's chats (default sort: ByCreateTimeAsc) +lark-cli im +chat-list + +# Sort by recent activity (most recently active first) +lark-cli im +chat-list --sort-type ByActiveTimeDesc + +# Limit page size +lark-cli im +chat-list --page-size 50 + +# Pagination +lark-cli im +chat-list --page-token "xxx" + +# Drop muted chats (user identity only) +lark-cli im +chat-list --exclude-muted + +# JSON output +lark-cli im +chat-list --format json + +# Preview the request without executing it +lark-cli im +chat-list --dry-run +``` + +## Parameters + +| Parameter | Required | Limits | Description | +|------|------|------|------| +| `--user-id-type ` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response | +| `--sort-type ` | No | `ByCreateTimeAsc` (default), `ByActiveTimeDesc` | Result ordering | +| `--page-size ` | No | 1-100, default 20 | Number of results per page | +| `--page-token ` | No | - | Pagination token from the previous response | +| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below | +| `--format json` | No | - | Output as JSON | +| `--dry-run` | No | - | Preview the request without executing it | + +> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled. + +## Output Fields + +| Field | Description | +|------|------| +| `chat_id` | Chat ID (`oc_xxx` format) | +| `name` | Chat name | +| `description` | Chat description | +| `owner_id` | Owner ID (type controlled by `--user-id-type`) | +| `external` | Whether the chat is external | +| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) | + +## Filtering muted chats + +`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the list call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped. + +When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds: + +```json +{ + "chats": [...], + "filter": { + "applied": "exclude_muted", + "fetched_count": 20, + "returned_count": 17, + "filtered_count": 3, + "hint": "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more." + } +} +``` + +## Usage Scenarios + +### Scenario 1: List my recent chats + +```bash +lark-cli im +chat-list --sort-type ByActiveTimeDesc --page-size 10 +``` + +### Scenario 2: List my non-muted chats sorted by activity + +```bash +lark-cli im +chat-list --sort-type ByActiveTimeDesc --exclude-muted +``` + +### Scenario 3: Iterate all my chats programmatically + +```bash +TOKEN="" +while :; do + RESP=$(lark-cli im +chat-list --page-size 100 --page-token "$TOKEN" --format json) + echo "$RESP" | jq -r '.data.chats[].chat_id' + HAS_MORE=$(echo "$RESP" | jq -r '.data.has_more') + [ "$HAS_MORE" = "true" ] || break + TOKEN=$(echo "$RESP" | jq -r '.data.page_token') +done +``` + +## Common Errors and Troubleshooting + +| Symptom | Root Cause | Solution | +|---------|---------|---------| +| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 | +| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console | +| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` | +| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console | +| `--exclude-muted` returns all chats unfiltered and `hint` says "no effect under bot identity" | Running under `--as bot` (mute API is UAT-only) | Switch to `--as user` for mute filtering | diff --git a/skills/lark-im/references/lark-im-chat-messages-list.md b/skills/lark-im/references/lark-im-chat-messages-list.md index a3f61f0f6..d47022e2d 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -129,7 +129,7 @@ lark-cli api GET /open-apis/im/v1/messages \ lark-cli im +chat-search --query "" --format json lark-cli im +chat-messages-list --chat-id ``` - **Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.** + **Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.** 2. **Prefer `--chat-id` when available:** if the chat_id is already known, use it directly to avoid extra API calls. 3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually. This requires user identity (`--as user`); with bot identity, resolve the p2p `chat_id` yourself and pass it via `--chat-id`. 4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler. diff --git a/skills/lark-im/references/lark-im-chat-search.md b/skills/lark-im/references/lark-im-chat-search.md index 403f9da60..186bb7008 100644 --- a/skills/lark-im/references/lark-im-chat-search.md +++ b/skills/lark-im/references/lark-im-chat-search.md @@ -49,6 +49,7 @@ lark-cli im +chat-search --query "project" --dry-run | `--sort-by ` | No | `create_time_desc`, `update_time_desc`, `member_count_desc` | Sort field in descending order | | `--page-size ` | No | 1-100, default 20 | Number of results per page | | `--page-token ` | No | - | Pagination token from the previous response | +| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below | | `--format json` | No | - | Output as JSON | | `--dry-run` | No | - | Preview the request without executing it | @@ -65,6 +66,27 @@ lark-cli im +chat-search --query "project" --dry-run | `external` | Whether the chat is external | | `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) | +## Filtering muted chats + +`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the search call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped. + +When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds: + +```json +{ + "chats": [...], + "filter": { + "applied": "exclude_muted", + "fetched_count": 20, + "returned_count": 19, + "filtered_count": 1, + "hint": "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more." + } +} +``` + +Note: only confirmed-muted chats count toward `filtered_count`; non-member public groups are retained and surfaced in `hint`. For strict member-only results, combine with `--search-types "private,public_joined,external"`. + ## Usage Scenarios ### Scenario 1: Search chats that contain a keyword @@ -106,7 +128,7 @@ When the user asks to search chats, follow these rules: 2. **Search scope is limited:** only chats visible to the current user or bot can be found (joined chats plus public chats). This is not a global search over all chats. 3. **Control result volume:** the result set may be large. Use `--page-size` deliberately. 4. **Suggest follow-up actions:** after finding a chat, common next steps include listing recent messages (`im +chat-messages-list`) or sending a message (`im +messages-send`). -5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `im chats list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity. +5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `+chat-list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity. ## References diff --git a/skills/lark-im/references/lark-im-messages-search.md b/skills/lark-im/references/lark-im-messages-search.md index b28ee1711..b36ce0a97 100644 --- a/skills/lark-im/references/lark-im-messages-search.md +++ b/skills/lark-im/references/lark-im-messages-search.md @@ -168,7 +168,7 @@ lark-cli im +chat-search --query "" --format json lark-cli im +messages-search --query "keyword" --chat-id ``` -**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.** +**Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.** ## Work Summary / Report Generation