From eb0bd8a9abd4594b3392d8f0b8639c3b3019726a Mon Sep 17 00:00:00 2001 From: shanglei Date: Fri, 31 Jul 2026 18:47:55 +0800 Subject: [PATCH 1/6] feat(im): add --page-all to list commands and align page-size limits Four of the most-used im list commands lacked --page-all while sibling commands in the same domain had it, so callers that learned the flag on +messages-search kept passing it to +threads-messages-list and friends and got "unknown flag". Separately, +threads-messages-list declared a page-size ceiling of 500 while the server accepts 50, so oversized values were forwarded and came back as an opaque "field validation failed" with no indication of which field was wrong. Add --page-all/--page-limit to +threads-messages-list, +chat-messages-list, +chat-list and +chat-search, following the existing +flag-list implementation: pages are capped, has_more and page_token come from the last fetched page so callers can resume, reaching the cap with has_more=true reports an incomplete result on stderr, and a non-advancing page_token stops the loop. Progress goes to stderr; stdout carries data only. Raw items from every page are merged first, then message conversion, sender-name resolution, thread expansion, reaction enrichment and resource download run once over the merged set. Page-size ceilings for the nine paginated im commands now come from a single table with a table-driven test, out-of-range values are rejected locally with a structured validation error that names the limit, and no HTTP request is issued when validation fails. +chat-members-list moves off its hand-written bounds check onto the shared validator. +feed-group-list-item keeps its current ceiling of 50: the public specification for its endpoint is unavailable, so the value is left pending confirmation rather than guessed. --- shortcuts/im/builders_test.go | 9 +- shortcuts/im/coverage_additional_test.go | 6 +- shortcuts/im/im_chat_list.go | 95 +++- shortcuts/im/im_chat_list_test.go | 9 +- shortcuts/im/im_chat_members_list.go | 9 +- shortcuts/im/im_chat_messages_list.go | 98 +++- shortcuts/im/im_chat_search.go | 92 +++- shortcuts/im/im_feed_group_item_test.go | 2 +- shortcuts/im/im_feed_group_list.go | 6 +- shortcuts/im/im_feed_group_list_item.go | 6 +- shortcuts/im/im_flag_list.go | 6 +- shortcuts/im/im_list_page_all_test.go | 495 ++++++++++++++++++ shortcuts/im/im_messages_search.go | 14 +- shortcuts/im/im_page_size_limits.go | 61 +++ shortcuts/im/im_page_size_limits_test.go | 116 ++++ shortcuts/im/im_threads_messages_list.go | 102 +++- shortcuts/im/with_sender_name_test.go | 2 +- skills/lark-im/SKILL.md | 8 +- .../lark-im/references/lark-im-chat-list.md | 9 +- .../references/lark-im-chat-members-list.md | 2 +- .../references/lark-im-chat-messages-list.md | 9 +- .../lark-im/references/lark-im-chat-search.md | 9 +- .../lark-im-threads-messages-list.md | 12 +- .../im/im_list_page_all_dryrun_test.go | 71 +++ 24 files changed, 1171 insertions(+), 77 deletions(-) create mode 100644 shortcuts/im/im_list_page_all_test.go create mode 100644 shortcuts/im/im_page_size_limits.go create mode 100644 shortcuts/im/im_page_size_limits_test.go create mode 100644 tests/cli_e2e/im/im_list_page_all_dryrun_test.go diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index 8f15ede214..603a4751bb 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -63,8 +63,9 @@ func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string cmd := &cobra.Command{Use: "test"} cmd.Flags().Int("page-size", 20, "") + cmd.Flags().Int("page-limit", 10, "") for name := range stringFlags { - if name == "page-size" { + if name == "page-size" || name == "page-limit" { continue } cmd.Flags().String(name, "", "") @@ -330,7 +331,7 @@ func TestShortcutValidateBranches(t *testing.T) { "page-size": "0", }, nil) err := ImChatSearch.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 100") { + if err == nil || !strings.Contains(err.Error(), "invalid --page-size 0: must be between 1 and 100") { t.Fatalf("ImChatSearch.Validate() error = %v", err) } }) @@ -700,7 +701,7 @@ func TestShortcutValidateBranches(t *testing.T) { "page-size": "0", }, nil) err := ImMessagesSearch.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 50") { + if err == nil || !strings.Contains(err.Error(), "invalid --page-size 0: must be between 1 and 50") { t.Fatalf("ImMessagesSearch.Validate() error = %v", err) } }) @@ -881,7 +882,7 @@ func TestShortcutDryRunShapes(t *testing.T) { t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) { runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{ "query": "incident", - "page-size": "51", + "page-size": "50", "page-token": "next_page", }, nil) got := mustMarshalDryRun(t, ImMessagesSearch.DryRun(context.Background(), runtime)) diff --git a/shortcuts/im/coverage_additional_test.go b/shortcuts/im/coverage_additional_test.go index 8d441ca68a..6eac3e0962 100644 --- a/shortcuts/im/coverage_additional_test.go +++ b/shortcuts/im/coverage_additional_test.go @@ -195,7 +195,7 @@ func TestBuildChatMessageListRequest(t *testing.T) { t.Run("valid request", func(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "sort": "asc", - "page-size": "80", + "page-size": "50", "page-token": "next", "start": "2026-03-01T00:00:00+08:00", "end": "2026-03-02T23:59:59+08:00", @@ -245,7 +245,7 @@ func TestBuildChatMessageListRequest(t *testing.T) { } func TestChatMessageListOnlyThreadRootMessagesParams(t *testing.T) { - got := buildChatMessageListParams("desc", "20", "oc_123") + got := buildChatMessageListParams("desc", 20, "oc_123") if vals := got["only_thread_root_messages"]; !reflect.DeepEqual(vals, []string{"true"}) { t.Fatalf("only_thread_root_messages = %#v, want true", vals) } @@ -341,7 +341,7 @@ func TestBuildMessagesSearchRequest(t *testing.T) { "exclude-sender-type": "bot", "start": "2026-03-01T00:00:00+08:00", "end": "2026-03-02T23:59:59+08:00", - "page-size": "80", + "page-size": "50", "page-token": "next-token", }, map[string]bool{ "at-all": true, diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go index 2ae1ffd46f..6ef527bcef 100644 --- a/shortcuts/im/im_chat_list.go +++ b/shortcuts/im/im_chat_list.go @@ -14,8 +14,12 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -// imChatListPath is the upstream HTTP path for the +chat-list shortcut. -const imChatListPath = "/open-apis/im/v1/chats" +const ( + // imChatListPath is the upstream HTTP path for the +chat-list shortcut. + imChatListPath = "/open-apis/im/v1/chats" + chatListDefaultPageLimit = 10 + chatListMaximumPageLimit = 1000 +) // bot_strip_p2p is the request-level adjustment notice emitted when bot // identity receives a mixed --types containing "p2p": the p2p value is @@ -41,7 +45,7 @@ func writeBotStripP2pWarning(errOut io.Writer) { var ImChatList = common.Shortcut{ Service: "im", Command: "+chat-list", - Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)", + Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, auto-pagination, --exclude-muted (user-only)", Risk: "read", Scopes: []string{"im:chat:read"}, AuthTypes: []string{"user", "bot"}, @@ -51,8 +55,10 @@ var ImChatList = common.Shortcut{ {Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}}, {Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}}, {Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"}, - {Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"}, + {Name: "page-size", Type: "int", Default: "20", Desc: imPageSizeDescription("+chat-list")}, {Name: "page-token", Desc: "pagination token for next page"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"}, + {Name: "page-limit", Type: "int", Default: "10", Desc: "max pages with --page-all (default 10; configurable range 1-1000)"}, {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. @@ -65,15 +71,22 @@ var ImChatList = common.Shortcut{ if stripped { writeBotStripP2pWarning(runtime.IO().ErrOut) } - return common.NewDryRunAPI(). + dry := common.NewDryRunAPI() + if chatListShouldAutoPaginate(runtime) { + dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)") + } + return dry. GET(imChatListPath). Params(buildChatListParams(runtime, effective)) }, // Validate enforces flag preconditions: page-size bounds, --types element // enum, and the bot + single-p2p rejection (mixed types degrade in Execute). Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - 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") + if _, err := validateIMPageSize(runtime, "+chat-list", 20); err != nil { + return err + } + if n := runtime.Int("page-limit"); n < 1 || n > chatListMaximumPageLimit { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit") } parts, err := normalizeTypes(runtime.StrSlice("types")) if err != nil { @@ -85,7 +98,7 @@ var ImChatList = common.Shortcut{ } return nil }, - // Execute fetches one page of chats, optionally applies --exclude-muted + // Execute fetches one or more pages of chats, optionally applies --exclude-muted // via MaybeApplyMuteFilter, and renders the result. outData["filter"] is // populated only when --exclude-muted is set (backward compatible). // outData["notices"] is populated only when bot identity strips p2p from @@ -97,7 +110,13 @@ var ImChatList = common.Shortcut{ writeBotStripP2pWarning(runtime.IO().ErrOut) } params := buildChatListParams(runtime, effective) - resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil) + var resData map[string]interface{} + var err error + if chatListShouldAutoPaginate(runtime) { + resData, err = fetchChatListAllPages(runtime, params) + } else { + resData, err = runtime.CallAPITyped("GET", imChatListPath, params, nil) + } if err != nil { return err } @@ -197,6 +216,64 @@ var ImChatList = common.Shortcut{ }, } +func chatListShouldAutoPaginate(runtime *common.RuntimeContext) bool { + return runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") +} + +func fetchChatListAllPages(runtime *common.RuntimeContext, params map[string]interface{}) (map[string]interface{}, error) { + maxPages := runtime.Int("page-limit") + if maxPages < 1 { + maxPages = chatListDefaultPageLimit + } + if maxPages > chatListMaximumPageLimit { + maxPages = chatListMaximumPageLimit + } + + allItems := make([]interface{}, 0) + var lastData map[string]interface{} + var lastHasMore bool + var lastPageToken string + prevPageToken := "__START__" + delete(params, "page_token") + + for page := 0; page < maxPages; page++ { + if page > 0 { + params["page_token"] = lastPageToken + } + data, err := runtime.CallAPITyped("GET", imChatListPath, params, nil) + if err != nil { + return nil, err + } + lastData = data + if items, ok := data["items"].([]interface{}); ok { + allItems = append(allItems, items...) + } + lastHasMore, lastPageToken = common.PaginationMeta(data) + fmt.Fprintf(runtime.IO().ErrOut, "page %d: %d chats\n", page+1, len(allItems)) + + if !lastHasMore || lastPageToken == "" { + break + } + if lastPageToken == prevPageToken { + fmt.Fprintln(runtime.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop") + break + } + if page+1 >= maxPages { + fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages) + break + } + prevPageToken = lastPageToken + } + + if lastData == nil { + lastData = map[string]interface{}{} + } + lastData["items"] = allItems + lastData["has_more"] = lastHasMore + lastData["page_token"] = lastPageToken + return lastData, nil +} + // normalizeTypes validates and normalizes the --types slice already parsed by cobra. // cobra's StringSlice handles the CSV split automatically — both --types=p2p,group // and repeated --types p2p --types group arrive here as a 2-element []string, diff --git a/shortcuts/im/im_chat_list_test.go b/shortcuts/im/im_chat_list_test.go index 4fc4f06fde..25a50a2bda 100644 --- a/shortcuts/im/im_chat_list_test.go +++ b/shortcuts/im/im_chat_list_test.go @@ -30,8 +30,10 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cmd := &cobra.Command{Use: "test"} cmd.Flags().Int("page-size", 20, "") + cmd.Flags().Int("page-limit", 10, "") + cmd.Flags().Bool("page-all", false, "") for name := range stringFlags { - if name == "page-size" { + if name == "page-size" || name == "page-limit" { continue } if name == "types" { @@ -41,6 +43,9 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str } } for name := range boolFlags { + if name == "page-all" { + continue + } cmd.Flags().Bool(name, false, "") } if err := cmd.ParseFlags(nil); err != nil { @@ -296,10 +301,12 @@ func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags t.Helper() cmd := &cobra.Command{Use: "test"} cmd.Flags().Int("page-size", 20, "") + cmd.Flags().Int("page-limit", 10, "") cmd.Flags().String("user-id-type", "open_id", "") cmd.Flags().String("sort-type", "ByCreateTimeAsc", "") cmd.Flags().StringSlice("types", nil, "") cmd.Flags().String("page-token", "", "") + cmd.Flags().Bool("page-all", false, "") cmd.Flags().Bool("exclude-muted", false, "") cmd.Flags().Bool("dry-run", false, "") if err := cmd.ParseFlags(nil); err != nil { diff --git a/shortcuts/im/im_chat_members_list.go b/shortcuts/im/im_chat_members_list.go index d467af63ce..e2ed8ceb7e 100644 --- a/shortcuts/im/im_chat_members_list.go +++ b/shortcuts/im/im_chat_members_list.go @@ -20,7 +20,6 @@ import ( const ( imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list" chatMembersListDefaultPageSize = 20 - chatMembersListMaxPageSize = 100 // chatMembersListDefaultPageDelay throttles --page-all the same way the // generic paginateLoop does (200ms). It matters for tenants WITHOUT the // server-side member cap, where a large group drains many pages back to @@ -28,6 +27,8 @@ const ( chatMembersListDefaultPageDelay = 200 ) +var chatMembersListMaxPageSize = imPageSizeLimit("+chat-members-list") + // ImChatMembersList is the +chat-members-list shortcut: it lists chat members, // returning users and bots in separate buckets (users[]/bots[]). It owns its // pagination loop (mirroring the generic paginateLoop conventions: a per-page @@ -48,7 +49,7 @@ var ImChatMembersList = common.Shortcut{ {Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"}, {Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"}, {Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}}, - {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)}, + {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: imPageSizeDescription("+chat-members-list")}, {Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"}, {Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"}, @@ -67,8 +68,8 @@ var ImChatMembersList = common.Shortcut{ if !strings.HasPrefix(chatID, "oc_") { return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id") } - if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size") + if _, err := validateIMPageSize(runtime, "+chat-members-list", chatMembersListDefaultPageSize); err != nil { + return err } if n := runtime.Int("page-limit"); n < 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit") diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 6356e5ecc2..4389ffd788 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -17,10 +17,16 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) +const ( + chatMessagesListDefaultPageSize = 50 + chatMessagesListDefaultPageLimit = 10 + chatMessagesListMaxPageLimit = 1000 +) + var ImChatMessageList = common.Shortcut{ Service: "im", Command: "+chat-messages-list", - Description: "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", + Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/auto-pagination", Risk: "read", Scopes: []string{"im:message:readonly"}, UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"}, @@ -34,8 +40,10 @@ var ImChatMessageList = common.Shortcut{ {Name: "end", Desc: "end time (ISO 8601)"}, {Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}}, {Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}}, - {Name: "page-size", Default: "50", Desc: "page size (1-50)"}, + {Name: "page-size", Default: "50", Desc: imPageSizeDescription("+chat-messages-list")}, {Name: "page-token", Desc: "pagination token for next page"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"}, + {Name: "page-limit", Type: "int", Default: "10", Desc: "max pages with --page-all (default 10; configurable range 1-1000)"}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, downloadResourcesFlag, }, @@ -48,6 +56,9 @@ var ImChatMessageList = common.Shortcut{ if runtime.Str("user-id") != "" { d.Desc("(--user-id provided) Will resolve P2P chat_id via POST /open-apis/im/v1/chat_p2p/batch_query at execution time") } + if chatMessagesListShouldAutoPaginate(runtime) { + d.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)") + } params, err := buildChatMessageListRequest(runtime, chatId) if err != nil { return d.Desc(err.Error()) @@ -97,6 +108,9 @@ var ImChatMessageList = common.Shortcut{ return err } } + if n := runtime.Int("page-limit"); n < 1 || n > chatMessagesListMaxPageLimit { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit") + } chatId := runtime.Str("chat-id") if chatId == "" { @@ -106,6 +120,9 @@ var ImChatMessageList = common.Shortcut{ return err }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := validateIMPageSize(runtime, "+chat-messages-list", chatMessagesListDefaultPageSize); err != nil { + return err + } chatId, err := resolveChatIDForMessagesList(runtime, false) if err != nil { return err @@ -115,7 +132,12 @@ var ImChatMessageList = common.Shortcut{ return err } - data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil) + var data map[string]interface{} + if chatMessagesListShouldAutoPaginate(runtime) { + data, err = fetchChatMessagesListAllPages(runtime, params) + } else { + data, err = runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil) + } if err != nil { return err } @@ -188,17 +210,71 @@ var ImChatMessageList = common.Shortcut{ }, } +func chatMessagesListShouldAutoPaginate(runtime *common.RuntimeContext) bool { + return runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") +} + +func fetchChatMessagesListAllPages(runtime *common.RuntimeContext, params larkcore.QueryParams) (map[string]interface{}, error) { + maxPages := runtime.Int("page-limit") + if maxPages < 1 { + maxPages = chatMessagesListDefaultPageLimit + } + if maxPages > chatMessagesListMaxPageLimit { + maxPages = chatMessagesListMaxPageLimit + } + + allItems := make([]interface{}, 0) + var lastData map[string]interface{} + var lastHasMore bool + var lastPageToken string + prevPageToken := "__START__" + delete(params, "page_token") + + for page := 0; page < maxPages; page++ { + if page > 0 { + params["page_token"] = []string{lastPageToken} + } + data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil) + if err != nil { + return nil, err + } + lastData = data + if items, ok := data["items"].([]interface{}); ok { + allItems = append(allItems, items...) + } + lastHasMore, lastPageToken = common.PaginationMeta(data) + fmt.Fprintf(runtime.IO().ErrOut, "page %d: %d messages\n", page+1, len(allItems)) + + if !lastHasMore || lastPageToken == "" { + break + } + if lastPageToken == prevPageToken { + fmt.Fprintln(runtime.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop") + break + } + if page+1 >= maxPages { + fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages) + break + } + prevPageToken = lastPageToken + } + + if lastData == nil { + lastData = map[string]interface{}{} + } + lastData["items"] = allItems + lastData["has_more"] = lastHasMore + lastData["page_token"] = lastPageToken + return lastData, nil +} + // buildChatMessageListParams builds the shared API params for DryRun and Execute. // and params map construction that existed verbatim in both DryRun and Execute. -func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.QueryParams { +func buildChatMessageListParams(sortFlag string, pageSize int, chatId string) larkcore.QueryParams { sortType := "ByCreateTimeDesc" if sortFlag == "asc" { sortType = "ByCreateTimeAsc" } - pageSize := 50 - if n, err := strconv.Atoi(pageSizeStr); err == nil { - pageSize = min(max(n, 1), 50) - } return larkcore.QueryParams{ "container_id_type": []string{"chat"}, "container_id": []string{chatId}, @@ -217,7 +293,11 @@ func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) if old, ok := aliasFlagValue(runtime, "sort", "order"); ok { dir = old // old value is asc/desc -> must go through the same map, never pass through } - params := buildChatMessageListParams(dir, runtime.Str("page-size"), chatId) + pageSize, err := validateIMPageSize(runtime, "+chat-messages-list", chatMessagesListDefaultPageSize) + if err != nil { + return nil, err + } + params := buildChatMessageListParams(dir, pageSize, chatId) if startFlag := runtime.Str("start"); startFlag != "" { startTime, err := common.ParseTime(startFlag) diff --git a/shortcuts/im/im_chat_search.go b/shortcuts/im/im_chat_search.go index ef46946ecc..4e7b378292 100644 --- a/shortcuts/im/im_chat_search.go +++ b/shortcuts/im/im_chat_search.go @@ -16,6 +16,11 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +const ( + chatSearchDefaultPageLimit = 10 + chatSearchMaximumPageLimit = 1000 +) + // 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 @@ -23,7 +28,7 @@ import ( 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)", + 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, auto-pagination, and --exclude-muted (user identity only)", Risk: "read", Scopes: []string{"im:chat:read"}, AuthTypes: []string{"user", "bot"}, @@ -37,15 +42,21 @@ var ImChatSearch = common.Shortcut{ {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-size", Type: "int", Default: "20", Desc: imPageSizeDescription("+chat-search")}, {Name: "page-token", Desc: "pagination token for next page"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"}, + {Name: "page-limit", Type: "int", Default: "10", Desc: "max pages with --page-all (default 10; configurable range 1-1000)"}, {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(). + dry := common.NewDryRunAPI() + if chatSearchShouldAutoPaginate(runtime) { + dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)") + } + return dry. POST("/open-apis/im/v2/chats/search"). Params(params). Body(body) @@ -89,19 +100,28 @@ var ImChatSearch = common.Shortcut{ } } } - 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") + if _, err := validateIMPageSize(runtime, "+chat-search", 20); err != nil { + return err + } + if n := runtime.Int("page-limit"); n < 1 || n > chatSearchMaximumPageLimit { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit") } return nil }, - // Execute fetches one page, extracts per-item meta_data, optionally applies + // Execute fetches one or more pages, 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) + var resData map[string]interface{} + var err error + if chatSearchShouldAutoPaginate(runtime) { + resData, err = fetchChatSearchAllPages(runtime, params, body) + } else { + resData, err = runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body) + } if err != nil { return err } @@ -207,6 +227,64 @@ var ImChatSearch = common.Shortcut{ }, } +func chatSearchShouldAutoPaginate(runtime *common.RuntimeContext) bool { + return runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") +} + +func fetchChatSearchAllPages(runtime *common.RuntimeContext, params, body map[string]interface{}) (map[string]interface{}, error) { + maxPages := runtime.Int("page-limit") + if maxPages < 1 { + maxPages = chatSearchDefaultPageLimit + } + if maxPages > chatSearchMaximumPageLimit { + maxPages = chatSearchMaximumPageLimit + } + + allItems := make([]interface{}, 0) + var lastData map[string]interface{} + var lastHasMore bool + var lastPageToken string + prevPageToken := "__START__" + delete(params, "page_token") + + for page := 0; page < maxPages; page++ { + if page > 0 { + params["page_token"] = lastPageToken + } + data, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body) + if err != nil { + return nil, err + } + lastData = data + if items, ok := data["items"].([]interface{}); ok { + allItems = append(allItems, items...) + } + lastHasMore, lastPageToken = common.PaginationMeta(data) + fmt.Fprintf(runtime.IO().ErrOut, "page %d: %d chats\n", page+1, len(allItems)) + + if !lastHasMore || lastPageToken == "" { + break + } + if lastPageToken == prevPageToken { + fmt.Fprintln(runtime.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop") + break + } + if page+1 >= maxPages { + fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages) + break + } + prevPageToken = lastPageToken + } + + if lastData == nil { + lastData = map[string]interface{}{} + } + lastData["items"] = allItems + lastData["has_more"] = lastHasMore + lastData["page_token"] = lastPageToken + return lastData, 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 diff --git a/shortcuts/im/im_feed_group_item_test.go b/shortcuts/im/im_feed_group_item_test.go index 9db5fb14f2..8c47bd2ae6 100644 --- a/shortcuts/im/im_feed_group_item_test.go +++ b/shortcuts/im/im_feed_group_item_test.go @@ -422,7 +422,7 @@ func TestFeedGroupValidationErrors(t *testing.T) { want string }{ {"list missing feed-group-id", ImFeedGroupListItem, map[string]string{}, "--feed-group-id is required"}, - {"list bad page-size", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-size": "0"}, "--page-size must be an integer between 1 and 50"}, + {"list bad page-size", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-size": "0"}, "invalid --page-size 0: must be between 1 and 50"}, {"list bad page-limit", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-limit": "2000"}, "--page-limit must be an integer between 1 and 1000"}, {"list bad start-time", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "start-time": "notnum"}, "--start-time must be Unix milliseconds"}, {"list bad end-time", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "end-time": "notnum"}, "--end-time must be Unix milliseconds"}, diff --git a/shortcuts/im/im_feed_group_list.go b/shortcuts/im/im_feed_group_list.go index c6bfdd0efc..5884be48f7 100644 --- a/shortcuts/im/im_feed_group_list.go +++ b/shortcuts/im/im_feed_group_list.go @@ -33,7 +33,7 @@ var ImFeedGroupList = common.Shortcut{ AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"}, + {Name: "page-size", Type: "int", Default: "50", Desc: imPageSizeDescription("+feed-group-list")}, {Name: "page-token", Desc: "pagination token for next page"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"}, {Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"}, @@ -72,8 +72,8 @@ var ImFeedGroupList = common.Shortcut{ } func validateFeedGroupListPageOptions(rt *common.RuntimeContext) error { - if n := rt.Int("page-size"); n < 1 || n > 50 { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size") + if _, err := validateIMPageSize(rt, "+feed-group-list", 50); err != nil { + return err } if n := rt.Int("page-limit"); n < 1 || n > 1000 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit") diff --git a/shortcuts/im/im_feed_group_list_item.go b/shortcuts/im/im_feed_group_list_item.go index e138d76263..541e99b6e2 100644 --- a/shortcuts/im/im_feed_group_list_item.go +++ b/shortcuts/im/im_feed_group_list_item.go @@ -28,7 +28,7 @@ var ImFeedGroupListItem = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"}, - {Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"}, + {Name: "page-size", Type: "int", Default: "50", Desc: imPageSizeDescription("+feed-group-list-item")}, {Name: "page-token", Desc: "pagination token for next page"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"}, {Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"}, @@ -72,8 +72,8 @@ func validateFeedGroupListOptions(rt *common.RuntimeContext) error { if rt.Str("feed-group-id") == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--feed-group-id is required").WithParam("--feed-group-id") } - if n := rt.Int("page-size"); n < 1 || n > 50 { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size") + if _, err := validateIMPageSize(rt, "+feed-group-list-item", 50); err != nil { + return err } if n := rt.Int("page-limit"); n < 1 || n > 1000 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit") diff --git a/shortcuts/im/im_flag_list.go b/shortcuts/im/im_flag_list.go index 9a0c52970f..7d73ed9cf4 100644 --- a/shortcuts/im/im_flag_list.go +++ b/shortcuts/im/im_flag_list.go @@ -25,7 +25,7 @@ var ImFlagList = common.Shortcut{ AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"}, + {Name: "page-size", Type: "int", Default: "50", Desc: imPageSizeDescription("+flag-list")}, {Name: "page-token", Desc: "pagination token for next page"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"}, {Name: "page-limit", Type: "int", Default: "20", Desc: "max pages with --page-all (default 20; configurable range 1-1000)"}, @@ -71,8 +71,8 @@ var ImFlagList = common.Shortcut{ } func validateListOptions(rt *common.RuntimeContext) error { - if n := rt.Int("page-size"); n < 1 || n > 50 { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size") + if _, err := validateIMPageSize(rt, "+flag-list", 50); err != nil { + return err } if n := rt.Int("page-limit"); n < 1 || n > 1000 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit") diff --git a/shortcuts/im/im_list_page_all_test.go b/shortcuts/im/im_list_page_all_test.go new file mode 100644 index 0000000000..85f93d451c --- /dev/null +++ b/shortcuts/im/im_list_page_all_test.go @@ -0,0 +1,495 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +type listPageAllCase struct { + name string + shortcut common.Shortcut + path string + method string + outputKey string + outputID string + baseFlags map[string]string + makeRawItem func(string) interface{} +} + +func listPageAllCases() []listPageAllCase { + messageItem := func(id string) interface{} { + return map[string]interface{}{ + "message_id": id, + "msg_type": "text", + "body": map[string]interface{}{"content": fmt.Sprintf(`{"text":%q}`, id)}, + "create_time": "0", + } + } + chatItem := func(id string) interface{} { + return map[string]interface{}{"chat_id": id, "name": id, "chat_mode": "group"} + } + searchItem := func(id string) interface{} { + return map[string]interface{}{"meta_data": chatItem(id)} + } + return []listPageAllCase{ + { + name: "chat-messages-list", shortcut: ImChatMessageList, + path: "/open-apis/im/v1/messages", method: http.MethodGet, + outputKey: "messages", outputID: "message_id", + baseFlags: map[string]string{"chat-id": "oc_test", "no-reactions": "true"}, + makeRawItem: messageItem, + }, + { + name: "threads-messages-list", shortcut: ImThreadsMessagesList, + path: "/open-apis/im/v1/messages", method: http.MethodGet, + outputKey: "messages", outputID: "message_id", + baseFlags: map[string]string{"thread": "omt_test", "no-reactions": "true"}, + makeRawItem: messageItem, + }, + { + name: "chat-list", shortcut: ImChatList, + path: "/open-apis/im/v1/chats", method: http.MethodGet, + outputKey: "chats", outputID: "chat_id", + baseFlags: map[string]string{}, + makeRawItem: chatItem, + }, + { + name: "chat-search", shortcut: ImChatSearch, + path: "/open-apis/im/v2/chats/search", method: http.MethodPost, + outputKey: "chats", outputID: "chat_id", + baseFlags: map[string]string{"query": "team"}, + makeRawItem: searchItem, + }, + } +} + +func newListPageAllCommand(t *testing.T, shortcut common.Shortcut, flags map[string]string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: shortcut.Command} + for _, flag := range shortcut.Flags { + switch flag.Type { + case "bool": + cmd.Flags().Bool(flag.Name, flag.Default == "true", flag.Desc) + case "int": + defaultValue := 0 + if flag.Default != "" { + defaultValue, _ = strconv.Atoi(flag.Default) + } + cmd.Flags().Int(flag.Name, defaultValue, flag.Desc) + case "string_slice": + cmd.Flags().StringSlice(flag.Name, nil, flag.Desc) + default: + cmd.Flags().String(flag.Name, flag.Default, flag.Desc) + } + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, value := range flags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s=%s: %v", name, value, err) + } + } + return cmd +} + +func mergeListPageAllFlags(base map[string]string, overrides map[string]string) map[string]string { + flags := make(map[string]string, len(base)+len(overrides)) + for name, value := range base { + flags[name] = value + } + for name, value := range overrides { + flags[name] = value + } + return flags +} + +func newListPageAllRuntime(t *testing.T, tc listPageAllCase, flags map[string]string, responder func(*http.Request, int) map[string]interface{}) (*common.RuntimeContext, *int) { + t.Helper() + calls := 0 + transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != tc.method || req.URL.Path != tc.path { + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String()) + } + calls++ + data := responder(req, calls) + return shortcutJSONResponse(http.StatusOK, map[string]interface{}{"code": 0, "data": data}), nil + }) + runtime := newUserShortcutRuntime(t, transport) + runtime.Cmd = newListPageAllCommand(t, tc.shortcut, mergeListPageAllFlags(tc.baseFlags, flags)) + runtime.Format = "json" + return runtime, &calls +} + +func listPageAllOutputData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} { + t.Helper() + out, ok := runtime.IO().Out.(*bytes.Buffer) + if !ok { + t.Fatal("stdout is not a bytes.Buffer") + } + var envelope map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, out.String()) + } + data, ok := envelope["data"].(map[string]interface{}) + if !ok { + t.Fatalf("stdout data has unexpected shape: %#v", envelope["data"]) + } + return data +} + +func assertListPageAllOrder(t *testing.T, data map[string]interface{}, tc listPageAllCase, want ...string) { + t.Helper() + items, ok := data[tc.outputKey].([]interface{}) + if !ok { + t.Fatalf("%s has unexpected shape: %#v", tc.outputKey, data[tc.outputKey]) + } + if len(items) != len(want) { + t.Fatalf("%s length = %d, want %d: %#v", tc.outputKey, len(items), len(want), items) + } + for i, item := range items { + row, _ := item.(map[string]interface{}) + if got, _ := row[tc.outputID].(string); got != want[i] { + t.Fatalf("%s[%d].%s = %q, want %q", tc.outputKey, i, tc.outputID, got, want[i]) + } + } +} + +func TestIMListPageAllMergesPagesAndUsesFinalPaginationMeta(t *testing.T) { + for _, tc := range listPageAllCases() { + t.Run(tc.name, func(t *testing.T) { + var requestTokens []string + runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true"}, func(req *http.Request, call int) map[string]interface{} { + requestTokens = append(requestTokens, req.URL.Query().Get("page_token")) + if call == 1 { + return map[string]interface{}{"items": []interface{}{tc.makeRawItem("first")}, "has_more": true, "page_token": "next", "total": 2} + } + return map[string]interface{}{"items": []interface{}{tc.makeRawItem("second")}, "has_more": false, "page_token": "final", "total": 2} + }) + if err := tc.shortcut.Validate(context.Background(), runtime); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if *calls != 2 { + t.Fatalf("API calls = %d, want 2", *calls) + } + if len(requestTokens) != 2 || requestTokens[0] != "" || requestTokens[1] != "next" { + t.Fatalf("request page tokens = %v, want [\"\" \"next\"]", requestTokens) + } + data := listPageAllOutputData(t, runtime) + assertListPageAllOrder(t, data, tc, "first", "second") + if hasMore, _ := data["has_more"].(bool); hasMore { + t.Fatalf("has_more = true, want final page value false") + } + if token, _ := data["page_token"].(string); token != "final" { + t.Fatalf("page_token = %q, want final", token) + } + }) + } +} + +func TestIMListPageAllStopsOnRepeatedToken(t *testing.T) { + for _, tc := range listPageAllCases() { + t.Run(tc.name, func(t *testing.T) { + runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true"}, func(_ *http.Request, call int) map[string]interface{} { + return map[string]interface{}{"items": []interface{}{tc.makeRawItem(fmt.Sprintf("item-%d", call))}, "has_more": true, "page_token": "same", "total": 10} + }) + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if *calls != 2 { + t.Fatalf("API calls = %d, want 2", *calls) + } + stderr := runtime.IO().ErrOut.(*bytes.Buffer).String() + if !strings.Contains(stderr, "page_token did not change") { + t.Fatalf("stderr missing repeated-token warning: %q", stderr) + } + if strings.Contains(stderr, "reached page limit") { + t.Fatalf("repeated token must not report a page-limit stop: %q", stderr) + } + }) + } +} + +func TestIMListPageAllReportsIncompleteResultOnPageLimit(t *testing.T) { + for _, tc := range listPageAllCases() { + t.Run(tc.name, func(t *testing.T) { + runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true", "page-limit": "2"}, func(_ *http.Request, call int) map[string]interface{} { + return map[string]interface{}{"items": []interface{}{tc.makeRawItem(fmt.Sprintf("item-%d", call))}, "has_more": true, "page_token": fmt.Sprintf("token-%d", call), "total": 10} + }) + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if *calls != 2 { + t.Fatalf("API calls = %d, want 2", *calls) + } + data := listPageAllOutputData(t, runtime) + assertListPageAllOrder(t, data, tc, "item-1", "item-2") + if hasMore, _ := data["has_more"].(bool); !hasMore { + t.Fatal("has_more = false, want true for incomplete result") + } + if token, _ := data["page_token"].(string); token != "token-2" { + t.Fatalf("page_token = %q, want token-2", token) + } + if _, exists := data["pages"]; exists { + t.Fatalf("output shape changed: unexpected pages field in %#v", data) + } + stderr := runtime.IO().ErrOut.(*bytes.Buffer).String() + for _, want := range []string{"reached page limit (2)", "has_more=true", "result is incomplete", "up to 1000", "page_token returned in stdout"} { + if !strings.Contains(stderr, want) { + t.Fatalf("stderr = %q, want %q", stderr, want) + } + } + stdout := runtime.IO().Out.(*bytes.Buffer).String() + for _, forbidden := range []string{"[pagination]", "result is incomplete", "Increase --page-limit"} { + if strings.Contains(stdout, forbidden) { + t.Fatalf("stdout contains pagination notice %q: %s", forbidden, stdout) + } + } + }) + } +} + +func TestIMListExplicitPageTokenDisablesPageAll(t *testing.T) { + for _, tc := range listPageAllCases() { + t.Run(tc.name, func(t *testing.T) { + runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true", "page-token": "resume"}, func(req *http.Request, _ int) map[string]interface{} { + if token := req.URL.Query().Get("page_token"); token != "resume" { + t.Fatalf("page_token = %q, want resume", token) + } + return map[string]interface{}{"items": []interface{}{tc.makeRawItem("only")}, "has_more": true, "page_token": "next", "total": 10} + }) + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if *calls != 1 { + t.Fatalf("API calls = %d, want 1", *calls) + } + data := listPageAllOutputData(t, runtime) + assertListPageAllOrder(t, data, tc, "only") + }) + } +} + +func TestIMListPageLimitValidation(t *testing.T) { + for _, tc := range listPageAllCases() { + for _, limit := range []string{"0", "1001"} { + t.Run(tc.name+"/"+limit, func(t *testing.T) { + runtime, _ := newListPageAllRuntime(t, tc, map[string]string{"page-limit": limit}, func(_ *http.Request, _ int) map[string]interface{} { + t.Fatal("validation must fail before an API request") + return nil + }) + err := tc.shortcut.Validate(context.Background(), runtime) + assertValidationError(t, tc.name, err, "--page-limit") + }) + } + } +} + +func TestIMListPageAllDryRunAndFlagSurface(t *testing.T) { + for _, tc := range listPageAllCases() { + t.Run(tc.name, func(t *testing.T) { + runtime, _ := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true"}, func(_ *http.Request, _ int) map[string]interface{} { + t.Fatal("dry-run must not make an API request") + return nil + }) + dryRun := mustMarshalDryRun(t, tc.shortcut.DryRun(context.Background(), runtime)) + var dryRunData map[string]interface{} + if err := json.Unmarshal([]byte(dryRun), &dryRunData); err != nil { + t.Fatalf("decode dry-run: %v", err) + } + if description, _ := dryRunData["description"].(string); description != "Auto-paginates through all pages (capped by --page-limit when > 0)" { + t.Fatalf("dry-run missing auto-pagination description: %s", dryRun) + } + + flags := make(map[string]common.Flag) + for _, flag := range tc.shortcut.Flags { + flags[flag.Name] = flag + } + if flag := flags["page-all"]; flag.Type != "bool" || flag.Desc != "automatically paginate, capped by --page-limit" { + t.Fatalf("page-all flag = %#v", flag) + } + if flag := flags["page-limit"]; flag.Type != "int" || flag.Default != "10" || !strings.Contains(flag.Desc, "1-1000") { + t.Fatalf("page-limit flag = %#v", flag) + } + }) + } +} + +func TestMessageListPageAllEnrichesMergedMessagesOnce(t *testing.T) { + messageItem := func(id string) interface{} { + return map[string]interface{}{ + "message_id": id, + "msg_type": "text", + "body": map[string]interface{}{"content": fmt.Sprintf(`{"text":%q}`, id)}, + "create_time": "0", + } + } + tests := []struct { + name string + shortcut common.Shortcut + flags map[string]string + }{ + {name: "chat-messages-list", shortcut: ImChatMessageList, flags: map[string]string{"chat-id": "oc_test", "page-all": "true"}}, + {name: "threads-messages-list", shortcut: ImThreadsMessagesList, flags: map[string]string{"thread": "omt_test", "page-all": "true"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pageCalls := 0 + reactionCalls := 0 + reactionQueries := 0 + transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/open-apis/im/v1/messages": + pageCalls++ + if pageCalls == 1 { + return shortcutJSONResponse(http.StatusOK, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{messageItem("first")}, "has_more": true, "page_token": "next"}, + }), nil + } + return shortcutJSONResponse(http.StatusOK, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{messageItem("second")}, "has_more": false, "page_token": "final"}, + }), nil + case "/open-apis/im/v1/messages/reactions/batch_query": + reactionCalls++ + var body struct { + Queries []map[string]interface{} `json:"queries"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatalf("decode reaction request: %v", err) + } + reactionQueries = len(body.Queries) + return shortcutJSONResponse(http.StatusOK, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": []interface{}{}, + "success_msg_reaction_details": []interface{}{}, + }, + }), nil + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String()) + return nil, nil + } + }) + runtime := newUserShortcutRuntime(t, transport) + runtime.Cmd = newListPageAllCommand(t, tc.shortcut, tc.flags) + runtime.Format = "json" + + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if pageCalls != 2 { + t.Fatalf("message page calls = %d, want 2", pageCalls) + } + if reactionCalls != 1 { + t.Fatalf("reaction batch calls = %d, want 1 after page merge", reactionCalls) + } + if reactionQueries != 2 { + t.Fatalf("reaction query count = %d, want both merged messages", reactionQueries) + } + }) + } +} + +func TestChatListPageAllFiltersMergedChatsOnce(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + path string + flags map[string]string + makeItem func(string) interface{} + }{ + { + name: "chat-list", shortcut: ImChatList, path: "/open-apis/im/v1/chats", + flags: map[string]string{"page-all": "true", "exclude-muted": "true"}, + makeItem: func(id string) interface{} { + return map[string]interface{}{"chat_id": id, "name": id, "chat_mode": "group"} + }, + }, + { + name: "chat-search", shortcut: ImChatSearch, path: "/open-apis/im/v2/chats/search", + flags: map[string]string{"query": "team", "page-all": "true", "exclude-muted": "true"}, + makeItem: func(id string) interface{} { + return map[string]interface{}{"meta_data": map[string]interface{}{"chat_id": id, "name": id, "chat_mode": "group"}} + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pageCalls := 0 + muteCalls := 0 + muteChatIDs := 0 + transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case tc.path: + pageCalls++ + if pageCalls == 1 { + return shortcutJSONResponse(http.StatusOK, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{tc.makeItem("oc_first")}, "has_more": true, "page_token": "next"}, + }), nil + } + return shortcutJSONResponse(http.StatusOK, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{tc.makeItem("oc_second")}, "has_more": false, "page_token": "final"}, + }), nil + case BatchGetMuteStatusPath: + muteCalls++ + var body struct { + ChatIDs []string `json:"chat_ids"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatalf("decode mute-status request: %v", err) + } + muteChatIDs = len(body.ChatIDs) + return shortcutJSONResponse(http.StatusOK, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"chat_id": "oc_first", "is_muted": false}, + map[string]interface{}{"chat_id": "oc_second", "is_muted": false}, + }, + }, + }), nil + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String()) + return nil, nil + } + }) + runtime := newUserShortcutRuntime(t, transport) + runtime.Cmd = newListPageAllCommand(t, tc.shortcut, tc.flags) + runtime.Format = "json" + + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if pageCalls != 2 { + t.Fatalf("chat page calls = %d, want 2", pageCalls) + } + if muteCalls != 1 { + t.Fatalf("mute-status calls = %d, want 1 after page merge", muteCalls) + } + if muteChatIDs != 2 { + t.Fatalf("mute-status chat ID count = %d, want both merged chats", muteChatIDs) + } + }) + } +} diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index f714006a7d..0c2be7946b 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -19,12 +19,13 @@ import ( const ( messagesSearchDefaultPageSize = 20 - messagesSearchMaxPageSize = 50 messagesSearchDefaultPageLimit = 20 messagesSearchMaxPageLimit = 40 messagesSearchMGetBatchSize = 50 ) +var messagesSearchMaxPageSize = imPageSizeLimit("+messages-search") + var ImMessagesSearch = common.Shortcut{ Service: "im", Command: "+messages-search", @@ -45,7 +46,7 @@ var ImMessagesSearch = common.Shortcut{ {Name: "at-chatter-ids", Desc: "filter by @mentioned user open_ids, comma-separated (also matches messages that @all)"}, {Name: "start", Desc: "start time(ISO 8601) with local timezone offset (e.g. 2026-03-24T00:00:00+08:00)"}, {Name: "end", Desc: "end time(ISO 8601) with local timezone offset (e.g. 2026-03-25T23:59:59+08:00)"}, - {Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-50)"}, + {Name: "page-size", Type: "int", Default: "20", Desc: imPageSizeDescription("+messages-search")}, {Name: "page-token", Desc: "page token"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate search results"}, {Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"}, @@ -365,12 +366,9 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch body["filter"] = filter } - pageSize := runtime.Int("page-size") - if pageSize < 1 { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size") - } - if pageSize > messagesSearchMaxPageSize { - pageSize = messagesSearchMaxPageSize + pageSize, err := validateIMPageSize(runtime, "+messages-search", messagesSearchDefaultPageSize) + if err != nil { + return nil, err } params := larkcore.QueryParams{ diff --git a/shortcuts/im/im_page_size_limits.go b/shortcuts/im/im_page_size_limits.go new file mode 100644 index 0000000000..e8be32a04f --- /dev/null +++ b/shortcuts/im/im_page_size_limits.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "fmt" + + "github.com/larksuite/cli/shortcuts/common" +) + +const imPageSizeMinimum = 1 + +// imPageSizeLimits is the single source of truth for shortcut page-size +// declarations and local validation in the IM domain. +// +// Verified against the corresponding OpenAPI contract or a read-only request: +// - GET /open-apis/im/v1/messages: 50 +// - POST /open-apis/im/v1/messages/search: 50 +// - GET /open-apis/im/v1/flags: 50 +// - GET /open-apis/im/v1/groups: 50 +// - POST /open-apis/im/v2/chats/search: 100 +// - GET /open-apis/im/v1/chats: 100 +// - GET /open-apis/im/v1/chats/:chat_id/members/list: 100 +// +// GET /open-apis/im/v1/groups/:group_id/list_item has no public specification. +// Its limit was established by probing the endpoint: page_size 51 and above +// returns code 230001 "param is invalid", 50 succeeds. +var imPageSizeLimits = map[string]int{ + "+threads-messages-list": 50, + "+chat-messages-list": 50, + "+messages-search": 50, + "+flag-list": 50, + "+feed-group-list": 50, + "+feed-group-list-item": 50, + "+chat-search": 100, + "+chat-list": 100, + "+chat-members-list": 100, +} + +func imPageSizeLimit(command string) int { + limit, ok := imPageSizeLimits[command] + if !ok { + panic(fmt.Sprintf("missing IM page-size limit for %s", command)) + } + return limit +} + +func imPageSizeDescription(command string) string { + return fmt.Sprintf("page size (1-%d)", imPageSizeLimit(command)) +} + +func validateIMPageSize(runtime *common.RuntimeContext, command string, defaultValue int) (int, error) { + return common.ValidatePageSizeTyped( + runtime, + "page-size", + defaultValue, + imPageSizeMinimum, + imPageSizeLimit(command), + ) +} diff --git a/shortcuts/im/im_page_size_limits_test.go b/shortcuts/im/im_page_size_limits_test.go new file mode 100644 index 0000000000..60f416c1a5 --- /dev/null +++ b/shortcuts/im/im_page_size_limits_test.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +type imPageSizeLimitCase struct { + shortcut common.Shortcut + flags map[string]string + limit int +} + +func imPageSizeLimitCases() []imPageSizeLimitCase { + return []imPageSizeLimitCase{ + {shortcut: ImThreadsMessagesList, flags: map[string]string{"thread": "omt_test"}, limit: 50}, + {shortcut: ImChatMessageList, flags: map[string]string{"chat-id": "oc_test"}, limit: 50}, + {shortcut: ImMessagesSearch, flags: map[string]string{"query": "test"}, limit: 50}, + {shortcut: ImFlagList, flags: map[string]string{}, limit: 50}, + {shortcut: ImFeedGroupList, flags: map[string]string{}, limit: 50}, + {shortcut: ImFeedGroupListItem, flags: map[string]string{"feed-group-id": "ofg_test"}, limit: 50}, + {shortcut: ImChatSearch, flags: map[string]string{"query": "test"}, limit: 100}, + {shortcut: ImChatList, flags: map[string]string{}, limit: 100}, + {shortcut: ImChatMembersList, flags: map[string]string{"chat-id": "oc_test"}, limit: 100}, + } +} + +func TestIMPageSizeLimitsTable(t *testing.T) { + want := map[string]int{ + "+threads-messages-list": 50, + "+chat-messages-list": 50, + "+messages-search": 50, + "+flag-list": 50, + "+feed-group-list": 50, + "+feed-group-list-item": 50, + "+chat-search": 100, + "+chat-list": 100, + "+chat-members-list": 100, + } + if !reflect.DeepEqual(imPageSizeLimits, want) { + t.Fatalf("imPageSizeLimits = %#v, want %#v", imPageSizeLimits, want) + } +} + +func TestIMPageSizeFlagsMatchLimitsTable(t *testing.T) { + for _, tc := range imPageSizeLimitCases() { + t.Run(tc.shortcut.Command, func(t *testing.T) { + if got := imPageSizeLimit(tc.shortcut.Command); got != tc.limit { + t.Fatalf("imPageSizeLimit(%q) = %d, want %d", tc.shortcut.Command, got, tc.limit) + } + var pageSizeFlag *common.Flag + for i := range tc.shortcut.Flags { + if tc.shortcut.Flags[i].Name == "page-size" { + pageSizeFlag = &tc.shortcut.Flags[i] + break + } + } + if pageSizeFlag == nil { + t.Fatal("page-size flag is missing") + } + if want := imPageSizeDescription(tc.shortcut.Command); pageSizeFlag.Desc != want { + t.Fatalf("page-size description = %q, want %q", pageSizeFlag.Desc, want) + } + }) + } +} + +func TestIMPageSizeValidationAcceptsLimitAndRejectsNextValue(t *testing.T) { + for _, tc := range imPageSizeLimitCases() { + t.Run(tc.shortcut.Command, func(t *testing.T) { + for _, test := range []struct { + name string + pageSize int + wantError bool + }{ + {name: "accepts-server-limit", pageSize: tc.limit}, + {name: "rejects-limit-plus-one", pageSize: tc.limit + 1, wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + requestCount := 0 + runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + requestCount++ + t.Fatalf("validation sent an HTTP request: %s %s", req.Method, req.URL.String()) + return nil, nil + })) + flags := mergeListPageAllFlags(tc.flags, map[string]string{"page-size": fmt.Sprintf("%d", test.pageSize)}) + runtime.Cmd = newListPageAllCommand(t, tc.shortcut, flags) + + err := tc.shortcut.Validate(context.Background(), runtime) + if !test.wantError { + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + } else { + assertValidationError(t, tc.shortcut.Command, err, "--page-size") + wantMessage := fmt.Sprintf("invalid --page-size %d: must be between 1 and %d", test.pageSize, tc.limit) + if err.Error() != wantMessage { + t.Fatalf("Validate() error = %q, want %q", err.Error(), wantMessage) + } + } + if requestCount != 0 { + t.Fatalf("HTTP request count = %d, want 0", requestCount) + } + }) + } + }) + } +} diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 1cdc83363b..034337121b 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -17,12 +17,17 @@ import ( convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" ) -const threadsMessagesMaxPageSize = 500 +const ( + threadsMessagesListDefaultPageLimit = 10 + threadsMessagesListMaxPageLimit = 1000 +) + +var threadsMessagesMaxPageSize = imPageSizeLimit("+threads-messages-list") var ImThreadsMessagesList = common.Shortcut{ Service: "im", Command: "+threads-messages-list", - Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination", + Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/auto-pagination", Risk: "read", Scopes: []string{"im:message:readonly"}, UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"}, @@ -33,8 +38,10 @@ var ImThreadsMessagesList = common.Shortcut{ {Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true}, {Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}}, {Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}}, - {Name: "page-size", Default: "50", Desc: "page size (1-500)"}, + {Name: "page-size", Default: "50", Desc: imPageSizeDescription("+threads-messages-list")}, {Name: "page-token", Desc: "page token"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"}, + {Name: "page-limit", Type: "int", Default: "10", Desc: "max pages with --page-all (default 10; configurable range 1-1000)"}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, downloadResourcesFlag, }, @@ -44,14 +51,19 @@ var ImThreadsMessagesList = common.Shortcut{ pageSizeStr := runtime.Str("page-size") pageToken := runtime.Str("page-token") - pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) - d := common.NewDryRunAPI() + pageSize, err := validateIMPageSize(runtime, "+threads-messages-list", threadsMessagesMaxPageSize) + if err != nil { + return d.Desc(err.Error()) + } containerID := threadFlag if messageIDRe.MatchString(threadFlag) { d.Desc("(--thread provided as message ID) Will resolve thread_id via GET /open-apis/im/v1/messages/:message_id at execution time") containerID = "" } + if threadsMessagesListShouldAutoPaginate(runtime) { + d.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)") + } params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken) @@ -76,10 +88,19 @@ var ImThreadsMessagesList = common.Shortcut{ if !strings.HasPrefix(threadId, "om_") && !strings.HasPrefix(threadId, "omt_") { return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --thread %q: must start with om_ or omt_", threadId).WithParam("--thread") } - _, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) - return err + if _, err := validateIMPageSize(runtime, "+threads-messages-list", threadsMessagesMaxPageSize); err != nil { + return err + } + if n := runtime.Int("page-limit"); n < 1 || n > threadsMessagesListMaxPageLimit { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit") + } + return nil }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + pageSize, err := validateIMPageSize(runtime, "+threads-messages-list", threadsMessagesMaxPageSize) + if err != nil { + return err + } threadId, err := resolveThreadID(runtime, runtime.Str("thread")) if err != nil { return err @@ -87,11 +108,14 @@ var ImThreadsMessagesList = common.Shortcut{ dir := resolveThreadsOrder(runtime) pageToken := runtime.Str("page-token") - pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) - params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken) - data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil) + var data map[string]interface{} + if threadsMessagesListShouldAutoPaginate(runtime) { + data, err = fetchThreadsMessagesListAllPages(runtime, params) + } else { + data, err = runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil) + } if err != nil { return err } @@ -162,6 +186,64 @@ var ImThreadsMessagesList = common.Shortcut{ }, } +func threadsMessagesListShouldAutoPaginate(runtime *common.RuntimeContext) bool { + return runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") +} + +func fetchThreadsMessagesListAllPages(runtime *common.RuntimeContext, params map[string][]string) (map[string]interface{}, error) { + maxPages := runtime.Int("page-limit") + if maxPages < 1 { + maxPages = threadsMessagesListDefaultPageLimit + } + if maxPages > threadsMessagesListMaxPageLimit { + maxPages = threadsMessagesListMaxPageLimit + } + + allItems := make([]interface{}, 0) + var lastData map[string]interface{} + var lastHasMore bool + var lastPageToken string + prevPageToken := "__START__" + delete(params, "page_token") + + for page := 0; page < maxPages; page++ { + if page > 0 { + params["page_token"] = []string{lastPageToken} + } + data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil) + if err != nil { + return nil, err + } + lastData = data + if items, ok := data["items"].([]interface{}); ok { + allItems = append(allItems, items...) + } + lastHasMore, lastPageToken = common.PaginationMeta(data) + fmt.Fprintf(runtime.IO().ErrOut, "page %d: %d thread messages\n", page+1, len(allItems)) + + if !lastHasMore || lastPageToken == "" { + break + } + if lastPageToken == prevPageToken { + fmt.Fprintln(runtime.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop") + break + } + if page+1 >= maxPages { + fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages) + break + } + prevPageToken = lastPageToken + } + + if lastData == nil { + lastData = map[string]interface{}{} + } + lastData["items"] = allItems + lastData["has_more"] = lastHasMore + lastData["page_token"] = lastPageToken + return lastData, nil +} + // buildThreadsMessagesListParams builds the upstream query params shared by // DryRun and Execute, so the asc/desc -> sort_type mapping lives in exactly one // place (precondition for the dry-run == real alias-parity test). diff --git a/shortcuts/im/with_sender_name_test.go b/shortcuts/im/with_sender_name_test.go index 5d2abff760..7e79216146 100644 --- a/shortcuts/im/with_sender_name_test.go +++ b/shortcuts/im/with_sender_name_test.go @@ -14,7 +14,7 @@ import ( // never appear (AC1/AC5). Covers chat-messages-list, threads-messages-list, and the // shared mget URL used by messages-mget and messages-search. func TestReadRequestsSendWithSenderName(t *testing.T) { - if got := buildChatMessageListParams("desc", "50", "oc_x")["with_sender_name"]; len(got) != 1 || got[0] != "true" { + if got := buildChatMessageListParams("desc", 50, "oc_x")["with_sender_name"]; len(got) != 1 || got[0] != "true" { t.Fatalf("chat-messages-list with_sender_name = %#v, want [true]", got) } if got := buildThreadsMessagesListParams("desc", "t_x", 50, "")["with_sender_name"]; len(got) != 1 || got[0] != "true" { diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index bc0b363e60..8c80ccd1da 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -104,17 +104,17 @@ 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 chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) | +| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, auto-pagination, --exclude-muted (user-only) | | [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket | -| [`+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, 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/auto-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, auto-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 | | [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type | | [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query | | [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key | -| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination | +| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/auto-pagination | | [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode) | | [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer | | [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; `--page-all` is capped by `--page-limit` (default 20, max 1000), and `has_more=true` means the result is incomplete | diff --git a/skills/lark-im/references/lark-im-chat-list.md b/skills/lark-im/references/lark-im-chat-list.md index 0d6ca2b283..5cf6947ade 100644 --- a/skills/lark-im/references/lark-im-chat-list.md +++ b/skills/lark-im/references/lark-im-chat-list.md @@ -23,6 +23,9 @@ lark-cli im +chat-list --page-size 50 # Pagination lark-cli im +chat-list --page-token "xxx" +# Fetch multiple pages automatically, up to 10 pages by default +lark-cli im +chat-list --page-all + # Drop muted chats (user identity only) lark-cli im +chat-list --exclude-muted @@ -51,12 +54,16 @@ lark-cli im +chat-list --as user --types p2p | `--sort ` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering | | `--page-size ` | No | 1-100, default 20 | Number of results per page | | `--page-token ` | No | - | Pagination token from the previous response | +| `--page-all` | No | - | Automatically fetch and merge subsequent pages; capped by `--page-limit` | +| `--page-limit ` | No | 1-1000, default 10 | Maximum pages fetched by `--page-all` | | `--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. +By default, the command fetches one page. With `--page-all`, it fetches and merges subsequent pages up to `--page-limit`. If the limit is reached while the output still has `has_more=true`, the result is incomplete; continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present. + ## Output Fields | Field | Description | @@ -156,7 +163,7 @@ done | 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 | +| `invalid --page-size 101: must be between 1 and 100` | page-size is out of range | 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 | diff --git a/skills/lark-im/references/lark-im-chat-members-list.md b/skills/lark-im/references/lark-im-chat-members-list.md index 9a22b9aedb..2728dbb242 100644 --- a/skills/lark-im/references/lark-im-chat-members-list.md +++ b/skills/lark-im/references/lark-im-chat-members-list.md @@ -78,6 +78,6 @@ A truncated result is *not* fixable by paging further — it is a server-side ca | Symptom | Root Cause | | Solution | |---------|---------|---|---------| | `--chat-id is required` | `--chat-id` omitted | | Provide the `oc_xxx` chat ID | -| `--page-size must be an integer between 1 and 100` | out of range | | Use 1-100 | +| `invalid --page-size 101: must be between 1 and 100` | out of range | | Use 1-100 | | `--member-types contains invalid value` | value other than `user`/`bot` | | Use `user`, `bot`, or both | | Permission denied | missing `im:chat.members:read` | | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:read"` | 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 d0f5af1856..350da279af 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -29,6 +29,9 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --order asc --page-size 20 # Pagination lark-cli im +chat-messages-list --chat-id oc_xxx --page-token "xxx" +# Fetch multiple pages automatically, up to 10 pages by default +lark-cli im +chat-messages-list --chat-id oc_xxx --page-all + # JSON output lark-cli im +chat-messages-list --chat-id oc_xxx --format json ``` @@ -44,6 +47,8 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json | `--order ` | No | Sort order: `asc` / `desc` (default `desc`) | | `--page-size ` | No | Page size (default 50, max 50) | | `--page-token ` | No | Pagination token | +| `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` | +| `--page-limit ` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) | | `--no-reactions` | No | Skip auto-fetching the `reactions` block | | `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default; no extra requests when omitted | @@ -106,12 +111,14 @@ Each message contains: ## Pagination (`has_more` / `page_token`) -`im +chat-messages-list` returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue: +By default, `im +chat-messages-list` fetches one page. It returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue: ```bash lark-cli im +chat-messages-list --chat-id oc_xxx --page-token ``` +Use `--page-all` to fetch and merge multiple pages. `--page-limit` defaults to 10 and accepts values from 1 to 1000. If the command reaches this limit while the output still has `has_more=true`, the result is incomplete; resume with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present. + You can also fall back to the generic API: ```bash diff --git a/skills/lark-im/references/lark-im-chat-search.md b/skills/lark-im/references/lark-im-chat-search.md index bdd93c0da2..d297de5330 100644 --- a/skills/lark-im/references/lark-im-chat-search.md +++ b/skills/lark-im/references/lark-im-chat-search.md @@ -33,6 +33,9 @@ lark-cli im +chat-search --query "project" --page-size 10 # Pagination lark-cli im +chat-search --query "project" --page-token "xxx" +# Fetch multiple pages automatically, up to 10 pages by default +lark-cli im +chat-search --query "project" --page-all + # JSON output lark-cli im +chat-search --query "project" --format json @@ -53,12 +56,16 @@ lark-cli im +chat-search --query "project" --dry-run | `--sort ` | No | `create_time`, `update_time`, `member_count` | Sort field (always descending) | | `--page-size ` | No | 1-100, default 20 | Number of results per page | | `--page-token ` | No | - | Pagination token from the previous response | +| `--page-all` | No | - | Automatically fetch and merge subsequent pages; capped by `--page-limit` | +| `--page-limit ` | No | 1-1000, default 10 | Maximum pages fetched by `--page-all` | | `--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 | > **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled. +By default, the command fetches one page. With `--page-all`, it fetches and merges subsequent pages up to `--page-limit`. If the limit is reached while the output still has `has_more=true`, the result is incomplete; continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present. + > **CAUTION:** `--sort` is **always descending** — the search API only ranks the chosen field high-to-low (e.g. `member_count` = most members first). There is no ascending option. If the user asks for "fewest first / ascending / 从少到多", tell them the search API does not support ascending order; any low-to-high view requires re-sorting the fetched page client-side and is not an upstream sort. Do **not** invent values like `member_count_asc` or pass `asc` (they are rejected). ## Output Fields @@ -121,7 +128,7 @@ lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Today's progress update" |---------|---------|---------| | `--query and --member-ids cannot both be empty` | Both were omitted | Provide at least `--query` or `--member-ids` | | Empty results | No visible chats matched the keyword or filters | Relax the keyword or filters and try again | -| `--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 | +| `invalid --page-size 101: must be between 1 and 100` | page-size is out of range | 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 | diff --git a/skills/lark-im/references/lark-im-threads-messages-list.md b/skills/lark-im/references/lark-im-threads-messages-list.md index 61c4acec7c..2c7053867c 100644 --- a/skills/lark-im/references/lark-im-threads-messages-list.md +++ b/skills/lark-im/references/lark-im-threads-messages-list.md @@ -23,6 +23,9 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-size 20 # Pagination lark-cli im +threads-messages-list --thread omt_xxx --page-token +# Fetch multiple pages automatically, up to 10 pages by default +lark-cli im +threads-messages-list --thread omt_xxx --page-all + # Output format options lark-cli im +threads-messages-list --thread omt_xxx --format pretty lark-cli im +threads-messages-list --thread omt_xxx --format table @@ -43,8 +46,10 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run | `--no-reactions` | No | Skip auto-fetching the `reactions` block | | `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default | | `--order ` | No | Sort order: `asc` (default) / `desc` | -| `--page-size ` | No | Number of items per page (default 50, range 1-500) | +| `--page-size ` | No | Number of items per page (default 50, range 1-50) | | `--page-token ` | No | Pagination token for the next page | +| `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` | +| `--page-limit ` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) | | `--format ` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` | | `--as ` | No | Identity type: `user` (default) / `bot` | | `--dry-run` | No | Print the request only, do not execute it | @@ -61,8 +66,9 @@ Thread messages do not support `start_time` / `end_time` filtering because of Fe ### 3. Pagination (`has_more` / `page_token`) -- When the result includes `has_more=true`, use `page_token` to fetch the next page -- If you need the complete thread, keep paginating; if you only need an overview, the first page is often enough +By default, the command fetches one page. When the result includes `has_more=true`, use `page_token` to fetch the next page, or add `--page-all` to fetch and merge subsequent pages automatically. `--page-limit` defaults to 10 and accepts values from 1 to 1000. + +If automatic pagination reaches the limit while the output still has `has_more=true`, the result is incomplete. Continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present. ### 4. Recommended expansion strategy diff --git a/tests/cli_e2e/im/im_list_page_all_dryrun_test.go b/tests/cli_e2e/im/im_list_page_all_dryrun_test.go new file mode 100644 index 0000000000..86df35dfbe --- /dev/null +++ b/tests/cli_e2e/im/im_list_page_all_dryrun_test.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "net/http" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +func TestIM_ListPageAllDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + tests := []struct { + name string + args []string + method string + path string + }{ + { + name: "chat-messages-list", + args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun"}, + method: http.MethodGet, + path: "/open-apis/im/v1/messages", + }, + { + name: "threads-messages-list", + args: []string{"im", "+threads-messages-list", "--thread", "omt_dryrun"}, + method: http.MethodGet, + path: "/open-apis/im/v1/messages", + }, + { + name: "chat-list", + args: []string{"im", "+chat-list"}, + method: http.MethodGet, + path: "/open-apis/im/v1/chats", + }, + { + name: "chat-search", + args: []string{"im", "+chat-search", "--query", "team"}, + method: http.MethodPost, + path: "/open-apis/im/v2/chats/search", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{}, tc.args...) + args = append(args, "--page-all", "--page-limit", "3", "--dry-run") + result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"}) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, tc.method, clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out) + require.Equal(t, tc.path, clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out) + require.Equal(t, "Auto-paginates through all pages (capped by --page-limit when > 0)", clie2e.DryRunGet(out, "description").String(), "stdout:\n%s", out) + }) + } +} From 09b38a7292329577e3bb4c0af20942b9b488b6d1 Mon Sep 17 00:00:00 2001 From: shanglei Date: Sat, 1 Aug 2026 12:24:11 +0800 Subject: [PATCH 2/6] feat(im): accept the flag names callers actually type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six flags in the im domain are routinely typed under a different name — --start-time for --start, --thread-id for --thread, --message-id for --message-ids, --keyword for --query, --sort-order for --order and --limit for --page-size. The value written alongside them is already valid in every case; only the name is wrong, so the call fails once and has to be retried under the canonical name. Register the eight names as hidden aliases, following the existing pattern in this package: the canonical flag wins when both are given, --help and schema keep listing only the canonical name, and a note naming the canonical flag is written to stderr so callers learn it instead of settling on the alias. The four aliases that already existed now emit that note too. Out-of-range values report the flag the caller actually typed, so --limit 500 is rejected as --limit rather than as --page-size. --thread and --message-ids drop their Required declaration and validate in Validate instead, otherwise cobra rejects the call before an alias can be resolved. ParseTime gains "2006-01-02 15:04:05 Z07:00". A space-separated timestamp with an offset is the most common thing written after --start-time, and without this format the alias would only turn an unknown-flag error into a parse error. The format is additive: inputs that parsed before are unaffected. --- shortcuts/common/common.go | 1 + shortcuts/common/common_test.go | 10 + shortcuts/im/builders_test.go | 3 +- shortcuts/im/im_chat_messages_list.go | 25 +- shortcuts/im/im_flag_aliases_test.go | 317 ++++++++++++++++++ shortcuts/im/im_messages_mget.go | 17 +- shortcuts/im/im_messages_search.go | 11 +- shortcuts/im/im_page_size_limits.go | 6 +- shortcuts/im/im_threads_messages_list.go | 17 +- shortcuts/im/im_threads_messages_list_test.go | 4 +- shortcuts/im/sort_flags.go | 45 ++- shortcuts/im/sort_flags_test.go | 55 ++- .../references/lark-im-chat-messages-list.md | 8 +- .../references/lark-im-messages-mget.md | 2 +- .../references/lark-im-messages-search.md | 4 +- .../lark-im-threads-messages-list.md | 2 +- .../cli_e2e/im/im_flag_aliases_dryrun_test.go | 135 ++++++++ 17 files changed, 632 insertions(+), 30 deletions(-) create mode 100644 shortcuts/im/im_flag_aliases_test.go create mode 100644 tests/cli_e2e/im/im_flag_aliases_dryrun_test.go diff --git a/shortcuts/common/common.go b/shortcuts/common/common.go index 03d6794ee9..96f501857a 100644 --- a/shortcuts/common/common.go +++ b/shortcuts/common/common.go @@ -61,6 +61,7 @@ func ParseTime(input string, hint ...string) (string, error) { time.RFC3339, "2006-01-02T15:04Z07:00", "2006-01-02T15:04:05Z07:00", + "2006-01-02 15:04:05 Z07:00", } for _, f := range tzFormats { if t, err := time.Parse(f, input); err == nil { diff --git a/shortcuts/common/common_test.go b/shortcuts/common/common_test.go index 94a2c9599a..19f9a3f32d 100644 --- a/shortcuts/common/common_test.go +++ b/shortcuts/common/common_test.go @@ -33,6 +33,16 @@ func TestParseTimeUnix(t *testing.T) { } } +func TestParseTimeWithSpaceSeparatedTimezone(t *testing.T) { + got, err := ParseTime("2026-07-27 00:00:00 +08:00") + if err != nil { + t.Fatalf("ParseTime(space-separated timezone) error: %v", err) + } + if got != "1785081600" { + t.Fatalf("ParseTime(space-separated timezone) = %q, want 1785081600", got) + } +} + func TestParseTimeRejectsRelative(t *testing.T) { for _, input := range []string{"today", "tomorrow", "yesterday", "now", "this_week", "+3d", "-1w", "+2h", "-30m", "last_7_days"} { t.Run(input, func(t *testing.T) { diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index 603a4751bb..5d358852b4 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -95,9 +95,10 @@ func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]st cmd := &cobra.Command{Use: "test"} cmd.Flags().Int("page-size", 20, "") + cmd.Flags().Int("limit", 0, "") cmd.Flags().Int("page-limit", 20, "") for name := range stringFlags { - if name == "page-size" || name == "page-limit" { + if name == "page-size" || name == "limit" || name == "page-limit" { continue } cmd.Flags().String(name, "", "") diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 4389ffd788..32c453c542 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -37,10 +37,14 @@ var ImChatMessageList = common.Shortcut{ {Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"}, {Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"}, {Name: "start", Desc: "start time (ISO 8601)"}, + {Name: "start-time", Hidden: true, Desc: "alias of --start (hidden)"}, {Name: "end", Desc: "end time (ISO 8601)"}, + {Name: "end-time", Hidden: true, Desc: "alias of --end (hidden)"}, {Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}}, {Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}}, + {Name: "sort-order", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}}, {Name: "page-size", Default: "50", Desc: imPageSizeDescription("+chat-messages-list")}, + {Name: "limit", Hidden: true, Desc: "alias of --page-size (hidden)"}, {Name: "page-token", Desc: "pagination token for next page"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"}, {Name: "page-limit", Type: "int", Default: "10", Desc: "max pages with --page-all (default 10; configurable range 1-1000)"}, @@ -293,20 +297,35 @@ func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) if old, ok := aliasFlagValue(runtime, "sort", "order"); ok { dir = old // old value is asc/desc -> must go through the same map, never pass through } - pageSize, err := validateIMPageSize(runtime, "+chat-messages-list", chatMessagesListDefaultPageSize) + if old, ok := aliasFlagValue(runtime, "sort-order", "order"); ok { + dir = old + } + pageSizeFlag := "page-size" + if _, ok := aliasFlagValue(runtime, "limit", "page-size"); ok { + pageSizeFlag = "limit" + } + pageSize, err := validateIMPageSizeFlag(runtime, "+chat-messages-list", pageSizeFlag, chatMessagesListDefaultPageSize) if err != nil { return nil, err } params := buildChatMessageListParams(dir, pageSize, chatId) - if startFlag := runtime.Str("start"); startFlag != "" { + startFlag := runtime.Str("start") + if old, ok := aliasFlagValue(runtime, "start-time", "start"); ok { + startFlag = old + } + if startFlag != "" { startTime, err := common.ParseTime(startFlag) if err != nil { return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start") } params["start_time"] = []string{startTime} } - if endFlag := runtime.Str("end"); endFlag != "" { + endFlag := runtime.Str("end") + if old, ok := aliasFlagValue(runtime, "end-time", "end"); ok { + endFlag = old + } + if endFlag != "" { endTime, err := common.ParseTime(endFlag, "end") if err != nil { return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end") diff --git a/shortcuts/im/im_flag_aliases_test.go b/shortcuts/im/im_flag_aliases_test.go new file mode 100644 index 0000000000..dd2e6285ba --- /dev/null +++ b/shortcuts/im/im_flag_aliases_test.go @@ -0,0 +1,317 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestChatMessagesListAliasesMatchCanonicalRequest(t *testing.T) { + aliasRT := newMsgListTestRT(t, map[string]string{ + "chat-id": "oc_test", + "start-time": "2026-07-27 00:00:00 +08:00", + "end-time": "1785254400", + "sort-order": "asc", + "limit": "25", + }) + canonicalRT := newMsgListTestRT(t, map[string]string{ + "chat-id": "oc_test", + "start": "2026-07-27 00:00:00 +08:00", + "end": "1785254400", + "order": "asc", + "page-size": "25", + }) + + aliasParams, err := buildChatMessageListRequest(aliasRT, "oc_test") + if err != nil { + t.Fatal(err) + } + canonicalParams, err := buildChatMessageListRequest(canonicalRT, "oc_test") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(aliasParams, canonicalParams) { + t.Fatalf("alias request = %#v, canonical request = %#v", aliasParams, canonicalParams) + } +} + +func TestChatMessagesListCanonicalFlagsWinOverAliases(t *testing.T) { + bothRT := newMsgListTestRT(t, map[string]string{ + "chat-id": "oc_test", + "start": "2026-07-27 00:00:00 +08:00", + "start-time": "2026-07-26 00:00:00 +08:00", + "end": "2026-07-28 00:00:00 +08:00", + "end-time": "2026-07-29 00:00:00 +08:00", + "order": "asc", + "sort-order": "desc", + "page-size": "25", + "limit": "30", + }) + canonicalRT := newMsgListTestRT(t, map[string]string{ + "chat-id": "oc_test", + "start": "2026-07-27 00:00:00 +08:00", + "end": "2026-07-28 00:00:00 +08:00", + "order": "asc", + "page-size": "25", + }) + + got, err := buildChatMessageListRequest(bothRT, "oc_test") + if err != nil { + t.Fatal(err) + } + want, err := buildChatMessageListRequest(canonicalRT, "oc_test") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("both-set request = %#v, canonical request = %#v", got, want) + } +} + +func TestChatMessagesListLimitAliasKeepsPageSizeValidation(t *testing.T) { + rt := newMsgListTestRT(t, map[string]string{"limit": "100"}) + _, err := buildChatMessageListRequest(rt, "oc_test") + assertAliasValidationError(t, err, "--limit", "invalid --limit 100: must be between 1 and 50") +} + +func TestThreadsMessagesListThreadIDAlias(t *testing.T) { + aliasRT := newThreadsTestRT(t, map[string]string{"thread-id": "omt_alias"}) + canonicalRT := newThreadsTestRT(t, map[string]string{"thread": "omt_alias"}) + + if err := ImThreadsMessagesList.Validate(context.Background(), aliasRT); err != nil { + t.Fatalf("alias validation error = %v", err) + } + if got, want := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), aliasRT)), mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), canonicalRT)); got != want { + t.Fatalf("alias dry-run differs from canonical:\nalias=%s\ncanonical=%s", got, want) + } +} + +func TestThreadsMessagesListCanonicalThreadWins(t *testing.T) { + rt := newThreadsTestRT(t, map[string]string{ + "thread": "omt_canonical", + "thread-id": "omt_alias", + }) + if got := resolveThreadsInput(rt); got != "omt_canonical" { + t.Fatalf("resolveThreadsInput() = %q, want omt_canonical", got) + } +} + +func TestThreadsMessagesListStillRequiresThreadInput(t *testing.T) { + rt := newChatListTestRuntimeContext(t, map[string]string{}, nil) + err := ImThreadsMessagesList.Validate(context.Background(), rt) + assertAliasValidationError(t, err, "--thread", "--thread is required (om_xxx or omt_xxx)") +} + +func TestMessagesMGetMessageIDAlias(t *testing.T) { + aliasRT := newTestRuntimeContext(t, map[string]string{"message-id": "om_alias"}, nil) + canonicalRT := newTestRuntimeContext(t, map[string]string{"message-ids": "om_alias"}, nil) + + if err := ImMessagesMGet.Validate(context.Background(), aliasRT); err != nil { + t.Fatalf("alias validation error = %v", err) + } + if got, want := mustMarshalDryRun(t, ImMessagesMGet.DryRun(context.Background(), aliasRT)), mustMarshalDryRun(t, ImMessagesMGet.DryRun(context.Background(), canonicalRT)); got != want { + t.Fatalf("alias dry-run differs from canonical:\nalias=%s\ncanonical=%s", got, want) + } +} + +func TestMessagesMGetCanonicalMessageIDsWin(t *testing.T) { + rt := newTestRuntimeContext(t, map[string]string{ + "message-ids": "om_canonical", + "message-id": "om_alias", + }, nil) + if got := resolveMessageIDsInput(rt); got != "om_canonical" { + t.Fatalf("resolveMessageIDsInput() = %q, want om_canonical", got) + } +} + +func TestMessagesMGetStillRequiresMessageIDs(t *testing.T) { + rt := newTestRuntimeContext(t, map[string]string{}, nil) + err := ImMessagesMGet.Validate(context.Background(), rt) + assertAliasValidationError(t, err, "--message-ids", "--message-ids is required (comma-separated om_xxx)") +} + +func TestMessagesSearchAliasesMatchCanonicalRequest(t *testing.T) { + aliasRT := newMessagesSearchTestRuntimeContext(t, map[string]string{ + "keyword": "project", + "limit": "30", + }, nil) + canonicalRT := newMessagesSearchTestRuntimeContext(t, map[string]string{ + "query": "project", + "page-size": "30", + }, nil) + + aliasReq, err := buildMessagesSearchRequest(aliasRT) + if err != nil { + t.Fatal(err) + } + canonicalReq, err := buildMessagesSearchRequest(canonicalRT) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(aliasReq, canonicalReq) { + t.Fatalf("alias request = %#v, canonical request = %#v", aliasReq, canonicalReq) + } +} + +func TestMessagesSearchCanonicalFlagsWinOverAliases(t *testing.T) { + rt := newMessagesSearchTestRuntimeContext(t, map[string]string{ + "query": "canonical", + "keyword": "alias", + "page-size": "25", + "limit": "30", + }, nil) + req, err := buildMessagesSearchRequest(rt) + if err != nil { + t.Fatal(err) + } + if got := req.body["query"]; got != "canonical" { + t.Fatalf("query = %#v, want canonical", got) + } + if got := req.params["page_size"][0]; got != "25" { + t.Fatalf("page_size = %q, want 25", got) + } +} + +func TestMessagesSearchLimitAliasKeepsPageSizeValidation(t *testing.T) { + rt := newMessagesSearchTestRuntimeContext(t, map[string]string{"limit": "100"}, nil) + _, err := buildMessagesSearchRequest(rt) + assertAliasValidationError(t, err, "--limit", "invalid --limit 100: must be between 1 and 50") +} + +func TestIMFlagAliasesAreHiddenAndTypeCompatible(t *testing.T) { + tests := []struct { + shortcut *common.Shortcut + alias string + canonical string + }{ + {&ImChatMessageList, "start-time", "start"}, + {&ImChatMessageList, "end-time", "end"}, + {&ImChatMessageList, "sort-order", "order"}, + {&ImChatMessageList, "limit", "page-size"}, + {&ImThreadsMessagesList, "thread-id", "thread"}, + {&ImMessagesMGet, "message-id", "message-ids"}, + {&ImMessagesSearch, "keyword", "query"}, + {&ImMessagesSearch, "limit", "page-size"}, + } + + for _, tt := range tests { + t.Run(tt.shortcut.Command+"/"+tt.alias, func(t *testing.T) { + alias := findIMFlag(t, tt.shortcut, tt.alias) + canonical := findIMFlag(t, tt.shortcut, tt.canonical) + if !alias.Hidden { + t.Fatalf("--%s must be hidden", tt.alias) + } + if alias.Required { + t.Fatalf("--%s must not use Cobra required validation", tt.alias) + } + if alias.Type != canonical.Type { + t.Fatalf("--%s type = %q, --%s type = %q", tt.alias, alias.Type, tt.canonical, canonical.Type) + } + if !reflect.DeepEqual(alias.Enum, canonical.Enum) { + t.Fatalf("--%s enum = %v, --%s enum = %v", tt.alias, alias.Enum, tt.canonical, canonical.Enum) + } + if alias.Default != "" { + t.Fatalf("--%s default = %q, want empty", tt.alias, alias.Default) + } + }) + } + + if findIMFlag(t, &ImThreadsMessagesList, "thread").Required { + t.Fatal("--thread must use shortcut validation so --thread-id can satisfy the requirement") + } + if findIMFlag(t, &ImMessagesMGet, "message-ids").Required { + t.Fatal("--message-ids must use shortcut validation so --message-id can satisfy the requirement") + } +} + +func TestExistingIMAliasesNowWriteCanonicalNotes(t *testing.T) { + tests := []struct { + name string + rt *common.RuntimeContext + run func(*common.RuntimeContext) + note string + }{ + { + name: "chat list sort type", + rt: newChatListTestRuntimeContext(t, map[string]string{"sort-type": "ByActiveTimeDesc"}, nil), + run: func(rt *common.RuntimeContext) { _ = buildChatListParams(rt, "") }, + note: "note: --sort-type is an alias for --sort\n", + }, + { + name: "chat messages sort", + rt: newMsgListTestRT(t, map[string]string{"sort": "desc"}), + run: func(rt *common.RuntimeContext) { + _, _ = buildChatMessageListRequest(rt, "oc_test") + }, + note: "note: --sort is an alias for --order\n", + }, + { + name: "chat search sort by", + rt: newSearchTestRT(t, map[string]string{"query": "team", "sort-by": "create_time_desc"}), + run: func(rt *common.RuntimeContext) { _ = buildSearchChatBody(rt) }, + note: "note: --sort-by is an alias for --sort\n", + }, + { + name: "thread messages sort", + rt: newThreadsTestRT(t, map[string]string{"sort": "desc"}), + run: func(rt *common.RuntimeContext) { _ = resolveThreadsOrder(rt) }, + note: "note: --sort is an alias for --order\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.run(tt.rt) + if got := tt.rt.IO().ErrOut.(*bytes.Buffer).String(); got != tt.note { + t.Fatalf("stderr = %q, want %q", got, tt.note) + } + if got := tt.rt.IO().Out.(*bytes.Buffer).String(); got != "" { + t.Fatalf("alias note leaked to stdout: %q", got) + } + }) + } +} + +func findIMFlag(t *testing.T, shortcut *common.Shortcut, name string) *common.Flag { + t.Helper() + for i := range shortcut.Flags { + if shortcut.Flags[i].Name == name { + return &shortcut.Flags[i] + } + } + t.Fatalf("%s is missing --%s", shortcut.Command, name) + return nil +} + +func assertAliasValidationError(t *testing.T, err error, wantParam, wantMessage string) { + t.Helper() + if err == nil { + t.Fatal("expected validation error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %v", err, err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %#v", problem) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error is not *errs.ValidationError: %T %v", err, err) + } + if validationErr.Param != wantParam { + t.Fatalf("param = %q, want %q", validationErr.Param, wantParam) + } + if !strings.Contains(err.Error(), wantMessage) { + t.Fatalf("error = %q, want substring %q", err, wantMessage) + } +} diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index bf1a2e0a0e..e933263800 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -28,12 +28,13 @@ var ImMessagesMGet = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ - {Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true}, + {Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)"}, + {Name: "message-id", Hidden: true, Desc: "alias of --message-ids (hidden)"}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, downloadResourcesFlag, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - ids := common.SplitCSV(runtime.Str("message-ids")) + ids := common.SplitCSV(resolveMessageIDsInput(runtime)) d := common.NewDryRunAPI().GET(buildMGetURL(ids)) if !runtime.Bool("no-reactions") { d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). @@ -45,7 +46,7 @@ var ImMessagesMGet = common.Shortcut{ return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - ids := common.SplitCSV(runtime.Str("message-ids")) + ids := common.SplitCSV(resolveMessageIDsInput(runtime)) if len(ids) == 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids is required (comma-separated om_xxx)").WithParam("--message-ids") } @@ -60,7 +61,7 @@ var ImMessagesMGet = common.Shortcut{ return nil }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - ids := common.SplitCSV(runtime.Str("message-ids")) + ids := common.SplitCSV(resolveMessageIDsInput(runtime)) mgetURL := buildMGetURL(ids) data, err := runtime.DoAPIJSONTyped(http.MethodGet, mgetURL, nil, nil) @@ -127,3 +128,11 @@ var ImMessagesMGet = common.Shortcut{ return nil }, } + +func resolveMessageIDsInput(runtime *common.RuntimeContext) string { + messageIDs := runtime.Str("message-ids") + if old, ok := aliasFlagValue(runtime, "message-id", "message-ids"); ok { + messageIDs = old + } + return messageIDs +} diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index 0c2be7946b..85658b9ada 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -36,6 +36,7 @@ var ImMessagesSearch = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "query", Desc: "search keyword"}, + {Name: "keyword", Hidden: true, Desc: "alias of --query (hidden)"}, {Name: "chat-id", Desc: "limit to chat IDs, comma-separated"}, {Name: "sender", Desc: "sender open_ids, comma-separated"}, {Name: "include-attachment-type", Desc: "include attachment type filter", Enum: []string{"file", "image", "video", "link"}}, @@ -47,6 +48,7 @@ var ImMessagesSearch = common.Shortcut{ {Name: "start", Desc: "start time(ISO 8601) with local timezone offset (e.g. 2026-03-24T00:00:00+08:00)"}, {Name: "end", Desc: "end time(ISO 8601) with local timezone offset (e.g. 2026-03-25T23:59:59+08:00)"}, {Name: "page-size", Type: "int", Default: "20", Desc: imPageSizeDescription("+messages-search")}, + {Name: "limit", Type: "int", Hidden: true, Desc: "alias of --page-size (hidden)"}, {Name: "page-token", Desc: "page token"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate search results"}, {Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"}, @@ -265,6 +267,9 @@ type messagesSearchRequest struct { func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearchRequest, error) { query := runtime.Str("query") + if old, ok := aliasFlagValue(runtime, "keyword", "query"); ok { + query = old + } chatFlag := runtime.Str("chat-id") senderFlag := runtime.Str("sender") includeAttachmentTypeFlag := runtime.Str("include-attachment-type") @@ -366,7 +371,11 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch body["filter"] = filter } - pageSize, err := validateIMPageSize(runtime, "+messages-search", messagesSearchDefaultPageSize) + pageSizeFlag := "page-size" + if _, ok := aliasIntFlagValue(runtime, "limit", "page-size"); ok { + pageSizeFlag = "limit" + } + pageSize, err := validateIMPageSizeFlag(runtime, "+messages-search", pageSizeFlag, messagesSearchDefaultPageSize) if err != nil { return nil, err } diff --git a/shortcuts/im/im_page_size_limits.go b/shortcuts/im/im_page_size_limits.go index e8be32a04f..0b1e5d3763 100644 --- a/shortcuts/im/im_page_size_limits.go +++ b/shortcuts/im/im_page_size_limits.go @@ -51,9 +51,13 @@ func imPageSizeDescription(command string) string { } func validateIMPageSize(runtime *common.RuntimeContext, command string, defaultValue int) (int, error) { + return validateIMPageSizeFlag(runtime, command, "page-size", defaultValue) +} + +func validateIMPageSizeFlag(runtime *common.RuntimeContext, command, flagName string, defaultValue int) (int, error) { return common.ValidatePageSizeTyped( runtime, - "page-size", + flagName, defaultValue, imPageSizeMinimum, imPageSizeLimit(command), diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 034337121b..5d33fa3671 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -35,7 +35,8 @@ var ImThreadsMessagesList = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ - {Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true}, + {Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)"}, + {Name: "thread-id", Hidden: true, Desc: "alias of --thread (hidden)"}, {Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}}, {Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}}, {Name: "page-size", Default: "50", Desc: imPageSizeDescription("+threads-messages-list")}, @@ -46,7 +47,7 @@ var ImThreadsMessagesList = common.Shortcut{ downloadResourcesFlag, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - threadFlag := runtime.Str("thread") + threadFlag := resolveThreadsInput(runtime) dir := resolveThreadsOrder(runtime) pageSizeStr := runtime.Str("page-size") pageToken := runtime.Str("page-token") @@ -81,7 +82,7 @@ var ImThreadsMessagesList = common.Shortcut{ return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - threadId := runtime.Str("thread") + threadId := resolveThreadsInput(runtime) if threadId == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--thread is required (om_xxx or omt_xxx)").WithParam("--thread") } @@ -101,7 +102,7 @@ var ImThreadsMessagesList = common.Shortcut{ if err != nil { return err } - threadId, err := resolveThreadID(runtime, runtime.Str("thread")) + threadId, err := resolveThreadID(runtime, resolveThreadsInput(runtime)) if err != nil { return err } @@ -244,6 +245,14 @@ func fetchThreadsMessagesListAllPages(runtime *common.RuntimeContext, params map return lastData, nil } +func resolveThreadsInput(runtime *common.RuntimeContext) string { + threadID := runtime.Str("thread") + if old, ok := aliasFlagValue(runtime, "thread-id", "thread"); ok { + threadID = old + } + return threadID +} + // buildThreadsMessagesListParams builds the upstream query params shared by // DryRun and Execute, so the asc/desc -> sort_type mapping lives in exactly one // place (precondition for the dry-run == real alias-parity test). diff --git a/shortcuts/im/im_threads_messages_list_test.go b/shortcuts/im/im_threads_messages_list_test.go index f218b1cad5..b3779b6058 100644 --- a/shortcuts/im/im_threads_messages_list_test.go +++ b/shortcuts/im/im_threads_messages_list_test.go @@ -17,7 +17,9 @@ func newThreadsTestRT(t *testing.T, stringFlags map[string]string) *common.Runti stringFlags = map[string]string{} } if _, ok := stringFlags["thread"]; !ok { - stringFlags["thread"] = "omt_test" + if _, aliasSet := stringFlags["thread-id"]; !aliasSet { + stringFlags["thread"] = "omt_test" + } } return newChatListTestRuntimeContext(t, stringFlags, nil) } diff --git a/shortcuts/im/sort_flags.go b/shortcuts/im/sort_flags.go index bd1fad11d5..ba7261e446 100644 --- a/shortcuts/im/sort_flags.go +++ b/shortcuts/im/sort_flags.go @@ -3,16 +3,49 @@ package im -import "github.com/larksuite/cli/shortcuts/common" +import ( + "fmt" -// aliasFlagValue handles a renamed sort flag whose old name is kept as a silent -// alias. It returns (oldValue, true) only when the old flag was explicitly used -// and the new one was not; otherwise ("", false) — meaning "no old flag, or both -// given (new wins), so use the new-flag logic". Pure function, no IO: callable -// from DryRun, Execute, and minimal test fixtures alike. Never prints anything. + "github.com/larksuite/cli/shortcuts/common" +) + +const aliasFlagNoticeAnnotation = "lark-cli.im/alias-notice-emitted" + +// aliasFlagValue handles a renamed string flag whose old name is kept as a +// hidden alias. It returns (oldValue, true) only when the old flag was +// explicitly used and the new one was not. The canonical flag wins when both +// are present. A note is emitted once per invocation when the alias is used. func aliasFlagValue(rt *common.RuntimeContext, oldName, newName string) (string, bool) { if rt.Changed(oldName) && !rt.Changed(newName) { + emitAliasFlagNote(rt, oldName, newName) return rt.Str(oldName), true } return "", false } + +// aliasIntFlagValue is the typed equivalent of aliasFlagValue for int flags. +func aliasIntFlagValue(rt *common.RuntimeContext, oldName, newName string) (int, bool) { + if rt.Changed(oldName) && !rt.Changed(newName) { + emitAliasFlagNote(rt, oldName, newName) + return rt.Int(oldName), true + } + return 0, false +} + +func emitAliasFlagNote(rt *common.RuntimeContext, oldName, newName string) { + if rt == nil || rt.Cmd == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil { + return + } + flag := rt.Cmd.Flags().Lookup(oldName) + if flag == nil { + return + } + if len(flag.Annotations[aliasFlagNoticeAnnotation]) > 0 { + return + } + if flag.Annotations == nil { + flag.Annotations = make(map[string][]string) + } + flag.Annotations[aliasFlagNoticeAnnotation] = []string{newName} + fmt.Fprintf(rt.Factory.IOStreams.ErrOut, "note: --%s is an alias for --%s\n", oldName, newName) +} diff --git a/shortcuts/im/sort_flags_test.go b/shortcuts/im/sort_flags_test.go index 7fa8045912..37f0c1c457 100644 --- a/shortcuts/im/sort_flags_test.go +++ b/shortcuts/im/sort_flags_test.go @@ -4,8 +4,11 @@ package im import ( + "bytes" + "strings" "testing" + "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -26,7 +29,13 @@ func newAliasTestRT(t *testing.T, newName, newDefault, oldName string, set map[s t.Fatalf("Set(%q) error = %v", k, err) } } - return &common.RuntimeContext{Cmd: cmd} + return &common.RuntimeContext{ + Cmd: cmd, + Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + }}, + } } func TestAliasFlagValue(t *testing.T) { @@ -51,3 +60,47 @@ func TestAliasFlagValue(t *testing.T) { }) } } + +func TestAliasFlagValueWritesOneNoteToStderr(t *testing.T) { + rt := newAliasTestRT(t, "start", "", "start-time", map[string]string{ + "start-time": "2026-07-27 00:00:00 +08:00", + }) + + for range 2 { + if _, ok := aliasFlagValue(rt, "start-time", "start"); !ok { + t.Fatal("aliasFlagValue() did not select --start-time") + } + } + + stderr := rt.IO().ErrOut.(*bytes.Buffer).String() + if got := strings.Count(stderr, "note: --start-time is an alias for --start\n"); got != 1 { + t.Fatalf("alias note count = %d, want 1; stderr=%q", got, stderr) + } + if stdout := rt.IO().Out.(*bytes.Buffer).String(); stdout != "" { + t.Fatalf("alias note leaked to stdout: %q", stdout) + } +} + +func TestAliasIntFlagValue(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 20, "") + cmd.Flags().Int("limit", 0, "") + if err := cmd.Flags().Set("limit", "50"); err != nil { + t.Fatal(err) + } + rt := &common.RuntimeContext{ + Cmd: cmd, + Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + }}, + } + + got, ok := aliasIntFlagValue(rt, "limit", "page-size") + if !ok || got != 50 { + t.Fatalf("aliasIntFlagValue() = (%d, %v), want (50, true)", got, ok) + } + if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); stderr != "note: --limit is an alias for --page-size\n" { + t.Fatalf("stderr = %q", stderr) + } +} 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 350da279af..2133d1587d 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -42,10 +42,10 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json |------|------|------| | `--chat-id ` | One of two | Specify the conversation by its chat_id directly (e.g., group chat `oc_xxx`) | | `--user-id ` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically. Requires user identity (`--as user`); not supported with bot identity | -| `--start