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 8f15ede214..b7ca81b8ec 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -63,15 +63,26 @@ 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 []string{"query", "search-types", "chat-modes", "types", "member-ids", "sort", "sort-by", "page-token"} { + cmd.Flags().String(name, "", "") + } for name := range stringFlags { - if name == "page-size" { + if name == "page-size" || name == "page-limit" { continue } - cmd.Flags().String(name, "", "") + if cmd.Flags().Lookup(name) == nil { + cmd.Flags().String(name, "", "") + } } - for name := range boolFlags { + for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted", "page-all", "dry-run"} { cmd.Flags().Bool(name, false, "") } + for name := range boolFlags { + if cmd.Flags().Lookup(name) == nil { + cmd.Flags().Bool(name, false, "") + } + } if err := cmd.ParseFlags(nil); err != nil { t.Fatalf("ParseFlags() error = %v", err) } @@ -94,9 +105,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, "", "") @@ -330,7 +342,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 +712,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 +893,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/helpers.go b/shortcuts/im/helpers.go index 9fcd3c0e0d..8145002a40 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -82,12 +82,20 @@ func senderDisplay(sender map[string]interface{}) string { } func validateMessageID(input string) (string, error) { + return validateMessageIDForParam(input, "--message-id") +} + +// validateMessageIDForParam validates a message ID and attributes failures to +// the given flag name — callers that accept the value under a different flag +// (e.g. +messages-mget's --message-ids and its --message-id alias) pass the +// flag the caller actually typed. +func validateMessageIDForParam(input, param string) (string, error) { input = strings.TrimSpace(input) if input == "" { - return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam("--message-id") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam(param) } if !strings.HasPrefix(input, "om_") { - return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam("--message-id") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam(param) } return input, nil } diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 58a577b107..416cf25096 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -676,6 +676,82 @@ func TestShortcuts(t *testing.T) { } } +func TestValidateIMResourceDownloadRequiredFlags(t *testing.T) { + t.Run("both missing", func(t *testing.T) { + err := validateIMResourceDownloadRequiredFlags("", "") + if err == nil { + t.Fatal("validateIMResourceDownloadRequiredFlags() error = nil") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("ProblemOf() did not recognize %T", err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %+v", problem) + } + if problem.Message != "--file-key and --type are required" { + t.Fatalf("message = %q", problem.Message) + } + if !strings.Contains(problem.Hint, "+messages-mget") || !strings.Contains(problem.Hint, "--download-resources") { + t.Fatalf("hint = %q", problem.Hint) + } + + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T", err) + } + if len(validationErr.Params) != 2 || validationErr.Params[0].Name != "--file-key" || validationErr.Params[1].Name != "--type" { + t.Fatalf("params = %#v", validationErr.Params) + } + }) + + t.Run("one missing", func(t *testing.T) { + for _, tc := range []struct { + name string + fileKey string + fileType string + param string + }{ + {name: "file key", fileType: "image", param: "--file-key"}, + {name: "type", fileKey: "img_xxx", param: "--type"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateIMResourceDownloadRequiredFlags(tc.fileKey, tc.fileType) + assertValidationError(t, tc.name, err, tc.param) + problem, _ := errs.ProblemOf(err) + if !strings.Contains(problem.Hint, "+messages-mget") || !strings.Contains(problem.Hint, "--download-resources") { + t.Fatalf("hint = %q", problem.Hint) + } + }) + } + }) + + if err := validateIMResourceDownloadRequiredFlags("img_xxx", "image"); err != nil { + t.Fatalf("complete flags error = %v", err) + } +} + +func TestMessagesResourcesDownloadRequiredFlagDescriptions(t *testing.T) { + want := map[string]string{ + "file-key": "required", + "type": "required", + } + for _, flag := range ImMessagesResourcesDownload.Flags { + if needle, ok := want[flag.Name]; ok { + if flag.Required { + t.Errorf("--%s must be validated manually so the error can carry a hint", flag.Name) + } + if !strings.Contains(flag.Desc, needle) { + t.Errorf("--%s description = %q, want %q", flag.Name, flag.Desc, needle) + } + delete(want, flag.Name) + } + } + if len(want) != 0 { + t.Fatalf("missing flag declarations: %v", want) + } +} + // TestSenderDisplay covers the human-readable sender column: a resolved name wins, // otherwise the sender id is shown (AC3 fallback), and a system/senderless message // with neither yields an empty string (no name is normal, not an error). diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go index 2ae1ffd46f..031da07e5a 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"}, @@ -49,10 +53,12 @@ var ImChatList = common.Shortcut{ Flags: []common.Flag{ {Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}}, {Name: "sort", 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: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)"}, {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,25 @@ 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") + } + if err := validateAliasEnum(runtime, "sort-type", "sort", "ByCreateTimeAsc", "ByActiveTimeDesc"); err != nil { + return err } parts, err := normalizeTypes(runtime.StrSlice("types")) if err != nil { @@ -85,7 +101,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 +113,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 +219,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..9766c3074b 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 { @@ -686,8 +693,12 @@ func TestChatList_SortFlagSurface(t *testing.T) { if !aliasFlag.Hidden { t.Errorf("--sort-type must be Hidden") } - if got := strings.Join(aliasFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" { - t.Errorf("--sort-type Enum = %q, want ByCreateTimeAsc,ByActiveTimeDesc", got) + if len(aliasFlag.Enum) != 0 { + // A declared enum is framework-validated before canonical-wins + // resolution, so an inert alias value would fail the command even + // when --sort is present. The value set is enforced by + // validateAliasEnum in Validate instead. + t.Errorf("--sort-type (hidden alias) must not declare an Enum, got %q", aliasFlag.Enum) } if aliasFlag.Default != "" { t.Errorf("--sort-type (hidden alias) must not carry a Default, got %q", aliasFlag.Default) diff --git a/shortcuts/im/im_chat_members_list.go b/shortcuts/im/im_chat_members_list.go index d467af63ce..6188b5593f 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") @@ -76,8 +77,12 @@ var ImChatMembersList = common.Shortcut{ if n := runtime.Int("page-delay"); n < 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay") } - _, err := normalizeMemberTypes(runtime.StrSlice("member-types")) - return err + memberTypes := runtime.StrSlice("member-types") + if _, err := normalizeMemberTypes(memberTypes); err != nil { + return err + } + writeMemberTypesCompatibilityNotes(runtime.IO().ErrOut, memberTypes) + return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { chatID := strings.TrimSpace(runtime.Str("chat-id")) @@ -303,17 +308,30 @@ func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult { // normalizeMemberTypes validates the --member-types slice (already CSV-split by // cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return -// the API's default of all types). Any element outside {user, bot} is rejected. +// the API's default of all types). Plural spellings are normalized before +// validation. Every value is validated first; only then does an occurrence of +// all turn the whole filter into a no-op, so an invalid value alongside all +// (e.g. "admin,all") is still rejected instead of silently ignored. func normalizeMemberTypes(raw []string) (string, error) { if len(raw) == 0 { return "", nil } seen := make(map[string]struct{}, len(raw)) out := make([]string, 0, len(raw)) + hasAll := false for _, p := range raw { p = strings.TrimSpace(strings.ToLower(p)) + switch p { + case "users": + p = "user" + case "bots": + p = "bot" + case "all": + hasAll = true + continue + } if p != "user" && p != "bot" { - return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot, all", p).WithParam("--member-types") } if _, dup := seen[p]; dup { continue @@ -321,9 +339,42 @@ func normalizeMemberTypes(raw []string) (string, error) { seen[p] = struct{}{} out = append(out, p) } + if hasAll { + return "", nil + } return strings.Join(out, ","), nil } +func writeMemberTypesCompatibilityNotes(w io.Writer, raw []string) { + for _, value := range raw { + value = strings.TrimSpace(value) + if strings.EqualFold(value, "all") { + fmt.Fprintf(w, "note: --member-types %q means no filter (same as omitting the flag)\n", value) + return + } + } + + seen := make(map[string]struct{}, 2) + for _, value := range raw { + value = strings.TrimSpace(value) + canonical := "" + switch strings.ToLower(value) { + case "users": + canonical = "user" + case "bots": + canonical = "bot" + } + if canonical == "" { + continue + } + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} + fmt.Fprintf(w, "note: --member-types %q is accepted as %q\n", value, canonical) + } +} + // warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token // wins (single-page fetch from the supplied cursor) and --page-all is ignored. func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) { diff --git a/shortcuts/im/im_chat_members_list_test.go b/shortcuts/im/im_chat_members_list_test.go index f0da03009b..e0fbf848e0 100644 --- a/shortcuts/im/im_chat_members_list_test.go +++ b/shortcuts/im/im_chat_members_list_test.go @@ -164,6 +164,13 @@ func TestNormalizeMemberTypes(t *testing.T) { {nil, "", false}, {[]string{"user", "bot"}, "user,bot", false}, {[]string{"USER", "user"}, "user", false}, // lowercased + deduped + {[]string{"all"}, "", false}, + {[]string{"ALL"}, "", false}, + {[]string{"users", "bots"}, "user,bot", false}, + {[]string{"Users", "user", "Bots", "bot"}, "user,bot", false}, + {[]string{"user", "all"}, "", false}, + {[]string{"bots", "ALL"}, "", false}, + {[]string{"admin", "ALL"}, "", true}, // invalid value is rejected even when all is present {[]string{"admin"}, "", true}, {[]string{""}, "", true}, } @@ -182,6 +189,54 @@ func TestNormalizeMemberTypes(t *testing.T) { } } +func TestChatMembersListMemberTypesCompatibilityNotes(t *testing.T) { + cases := []struct { + name string + memberType string + want []string + }{ + { + name: "all", + memberType: "all,user", + want: []string{`note: --member-types "all" means no filter (same as omitting the flag)`}, + }, + { + name: "uppercase all", + memberType: "ALL", + want: []string{`note: --member-types "ALL" means no filter (same as omitting the flag)`}, + }, + { + name: "plural values", + memberType: "Users,bots", + want: []string{ + `note: --member-types "Users" is accepted as "user"`, + `note: --member-types "bots" is accepted as "bot"`, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runtime := newChatMembersTestRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(200, map[string]interface{}{"code": 0}), nil + }), map[string]string{"chat-id": "oc_test", "member-types": tc.memberType}, nil, nil) + + if err := ImChatMembersList.Validate(context.Background(), runtime); err != nil { + t.Fatalf("Validate() error = %v", err) + } + stderr := runtime.IO().ErrOut.(*bytes.Buffer).String() + for _, note := range tc.want { + if got := strings.Count(stderr, note); got != 1 { + t.Fatalf("note count = %d, want 1 for %q; stderr=%q", got, note, stderr) + } + } + if stdout := runtime.IO().Out.(*bytes.Buffer).String(); stdout != "" { + t.Fatalf("compatibility note leaked to stdout: %q", stdout) + } + }) + } +} + // TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior: // drain with no explicit size → max; explicit size → honored; single page → default. func TestEffectiveChatMembersPageSize(t *testing.T) { diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 6356e5ecc2..f922b203fe 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, --order asc|desc sorting, 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"}, @@ -31,11 +37,17 @@ 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: "page-size", Default: "50", Desc: "page size (1-50)"}, + {Name: "sort", Hidden: true, Desc: "alias of --order (hidden)"}, + {Name: "sort-order", Hidden: true, Desc: "alias of --order (hidden)"}, + {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)"}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, downloadResourcesFlag, }, @@ -48,6 +60,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 +112,15 @@ 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") + } + if err := validateAliasEnum(runtime, "sort", "order", "asc", "desc"); err != nil { + return err + } + if err := validateAliasEnum(runtime, "sort-order", "order", "asc", "desc"); err != nil { + return err + } chatId := runtime.Str("chat-id") if chatId == "" { @@ -106,6 +130,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 +142,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 +220,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,19 +303,42 @@ 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) + 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") + startParam := "--start" + if old, ok := aliasFlagValue(runtime, "start-time", "start"); ok { + startFlag = old + startParam = "--start-time" // attribute errors to the flag the caller actually typed + } + if startFlag != "" { startTime, err := common.ParseTime(startFlag) if err != nil { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %v", startParam, err).WithParam(startParam) } params["start_time"] = []string{startTime} } - if endFlag := runtime.Str("end"); endFlag != "" { + endFlag := runtime.Str("end") + endParam := "--end" + if old, ok := aliasFlagValue(runtime, "end-time", "end"); ok { + endFlag = old + endParam = "--end-time" + } + if endFlag != "" { endTime, err := common.ParseTime(endFlag, "end") if err != nil { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %v", endParam, err).WithParam(endParam) } params["end_time"] = []string{endTime} } diff --git a/shortcuts/im/im_chat_messages_list_test.go b/shortcuts/im/im_chat_messages_list_test.go index 76e97d7a87..0a406f713d 100644 --- a/shortcuts/im/im_chat_messages_list_test.go +++ b/shortcuts/im/im_chat_messages_list_test.go @@ -92,7 +92,9 @@ func TestChatMessagesList_OrderFlagSurface(t *testing.T) { if !aliasFlag.Hidden { t.Errorf("--sort must be Hidden") } - if got := strings.Join(aliasFlag.Enum, ","); got != "asc,desc" { - t.Errorf("--sort (alias) Enum = %q, want asc,desc", got) + if len(aliasFlag.Enum) != 0 { + // Enforced by validateAliasEnum in Validate; a declared enum would be + // framework-validated before canonical-wins resolution runs. + t.Errorf("--sort (hidden alias) must not declare an Enum, got %q", aliasFlag.Enum) } } diff --git a/shortcuts/im/im_chat_search.go b/shortcuts/im/im_chat_search.go index ef46946ecc..a54fec79c1 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"}, @@ -32,20 +37,27 @@ var ImChatSearch = common.Shortcut{ {Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"}, {Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"}, {Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"}, + {Name: "types", Hidden: true, Desc: "compatibility input handled by +chat-search validation; use --chat-modes or --search-types"}, {Name: "member-ids", Desc: "filter by member open_ids, comma-separated"}, {Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"}, {Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"}, {Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}}, - {Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}}, - {Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"}, + {Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)"}, + {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) @@ -58,6 +70,12 @@ var ImChatSearch = common.Shortcut{ if query == "" && memberIDs == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")") } + if err := applyChatSearchTypesCompatibility(runtime); err != nil { + return err + } + if err := validateAliasEnum(runtime, "sort-by", "sort", "create_time_desc", "update_time_desc", "member_count_desc"); err != nil { + return err + } if st := runtime.Str("search-types"); st != "" { allowed := map[string]struct{}{ "private": {}, @@ -89,19 +107,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 +234,109 @@ var ImChatSearch = common.Shortcut{ }, } +// applyChatSearchTypesCompatibility accepts the one observed cross-command +// spelling without treating --types as a normal alias. +chat-list and +// +chat-search use different value domains, so the value must be inspected +// before it can be mapped safely. An explicit --chat-modes always wins. +func applyChatSearchTypesCompatibility(runtime *common.RuntimeContext) error { + if !runtime.Changed("types") || runtime.Changed("chat-modes") { + return nil + } + + typesValue := runtime.Str("types") + types := common.SplitCSV(typesValue) + for _, chatType := range types { + if chatType == "p2p" { + return errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--types %q is invalid for im +chat-search: this command only searches group chats and the service does not support p2p; use im +chat-list --types p2p to list p2p chats", + typesValue, + ).WithParam("--types") + } + } + + onlyGroup := len(types) > 0 + for _, chatType := range types { + if chatType != "group" { + onlyGroup = false + break + } + } + if !onlyGroup { + return errs.NewValidationError( + errs.SubtypeInvalidArgument, + "invalid --types value %q for im +chat-search; use --chat-modes (group|topic) or --search-types (private|external|public_joined|public_not_joined)", + typesValue, + ).WithParam("--types") + } + + if err := runtime.Cmd.Flags().Set("chat-modes", "group"); err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "failed to map --types to --chat-modes").WithCause(err) + } + if runtime.Factory != nil && runtime.Factory.IOStreams != nil && runtime.Factory.IOStreams.ErrOut != nil { + fmt.Fprintln(runtime.Factory.IOStreams.ErrOut, "note: --types on +chat-search maps to --chat-modes") + } + return nil +} + +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_chat_search_test.go b/shortcuts/im/im_chat_search_test.go index 274de7110e..bfc321004a 100644 --- a/shortcuts/im/im_chat_search_test.go +++ b/shortcuts/im/im_chat_search_test.go @@ -4,9 +4,13 @@ package im import ( + "bytes" + "context" + "reflect" "strings" "testing" + "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/shortcuts/common" ) @@ -18,7 +22,14 @@ func newSearchTestRT(t *testing.T, stringFlags map[string]string) *common.Runtim if _, ok := stringFlags["query"]; !ok { stringFlags["query"] = "team" } - return newChatListTestRuntimeContext(t, stringFlags, nil) + rt := newChatSearchTestRuntimeContext(t, stringFlags, nil) + rt.Factory = &cmdutil.Factory{ + IOStreams: &cmdutil.IOStreams{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + }, + } + return rt } func TestChatSearch_SortMapping(t *testing.T) { @@ -96,7 +107,98 @@ func TestChatSearch_SortFlagSurface(t *testing.T) { if !aliasFlag.Hidden { t.Errorf("--sort-by must be Hidden") } - if got := strings.Join(aliasFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" { - t.Errorf("--sort-by Enum = %q", got) + if len(aliasFlag.Enum) != 0 { + // Enforced by validateAliasEnum in Validate; a declared enum would be + // framework-validated before canonical-wins resolution runs. + t.Errorf("--sort-by (hidden alias) must not declare an Enum, got %q", aliasFlag.Enum) + } +} + +func TestChatSearch_TypesGroupMatchesChatModesGroup(t *testing.T) { + for _, typesValue := range []string{"group", "group,group"} { + t.Run(typesValue, func(t *testing.T) { + typesRT := newSearchTestRT(t, map[string]string{"types": typesValue}) + if err := ImChatSearch.Validate(context.Background(), typesRT); err != nil { + t.Fatalf("Validate() error = %v", err) + } + canonicalRT := newSearchTestRT(t, map[string]string{"chat-modes": "group"}) + + typesBody := buildSearchChatBody(typesRT) + canonicalBody := buildSearchChatBody(canonicalRT) + if !reflect.DeepEqual(typesBody, canonicalBody) { + t.Fatalf("--types body = %#v, --chat-modes body = %#v", typesBody, canonicalBody) + } + filter, _ := typesBody["filter"].(map[string]interface{}) + if got := filter["chat_modes"]; !reflect.DeepEqual(got, []string{"default"}) { + t.Fatalf("filter.chat_modes = %#v, want []string{\"default\"}", got) + } + + stderr := typesRT.IO().ErrOut.(*bytes.Buffer).String() + if stderr != "note: --types on +chat-search maps to --chat-modes\n" { + t.Fatalf("stderr = %q", stderr) + } + if stdout := typesRT.IO().Out.(*bytes.Buffer).String(); stdout != "" { + t.Fatalf("mapping note leaked to stdout: %q", stdout) + } + }) + } +} + +func TestChatSearch_TypesP2PReturnsActionableValidationError(t *testing.T) { + for _, typesValue := range []string{"p2p", "group,p2p"} { + t.Run(typesValue, func(t *testing.T) { + rt := newSearchTestRT(t, map[string]string{"types": typesValue}) + err := ImChatSearch.Validate(context.Background(), rt) + assertAliasValidationError(t, err, "--types", "im +chat-list --types p2p") + if !strings.Contains(err.Error(), "service does not support p2p") { + t.Fatalf("error = %q, want service p2p limitation", err) + } + }) + } +} + +func TestChatSearch_TypesUnknownListsCanonicalValueDomains(t *testing.T) { + rt := newSearchTestRT(t, map[string]string{"types": "xxx"}) + err := ImChatSearch.Validate(context.Background(), rt) + assertAliasValidationError(t, err, "--types", "--chat-modes (group|topic)") + if !strings.Contains(err.Error(), "--search-types (private|external|public_joined|public_not_joined)") { + t.Fatalf("error = %q, want --search-types values", err) + } +} + +func TestChatSearch_ChatModesWinsOverTypes(t *testing.T) { + rt := newSearchTestRT(t, map[string]string{ + "types": "p2p", + "chat-modes": "topic", + }) + if err := ImChatSearch.Validate(context.Background(), rt); err != nil { + t.Fatalf("Validate() error = %v", err) + } + body := buildSearchChatBody(rt) + filter, _ := body["filter"].(map[string]interface{}) + if got := filter["chat_modes"]; !reflect.DeepEqual(got, []string{"thread"}) { + t.Fatalf("filter.chat_modes = %#v, want []string{\"thread\"}", got) + } + if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); stderr != "" { + t.Fatalf("ignored --types emitted stderr: %q", stderr) + } +} + +func TestChatSearch_TypesFlagIsHiddenAndHasNoEnum(t *testing.T) { + var typesFlag *common.Flag + for i := range ImChatSearch.Flags { + if ImChatSearch.Flags[i].Name == "types" { + typesFlag = &ImChatSearch.Flags[i] + break + } + } + if typesFlag == nil { + t.Fatal("--types flag is missing") + } + if !typesFlag.Hidden { + t.Fatal("--types must be hidden") + } + if len(typesFlag.Enum) != 0 { + t.Fatalf("--types enum = %v, want custom validation", typesFlag.Enum) } } 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_aliases_test.go b/shortcuts/im/im_flag_aliases_test.go new file mode 100644 index 0000000000..7c2247bee7 --- /dev/null +++ b/shortcuts/im/im_flag_aliases_test.go @@ -0,0 +1,377 @@ +// 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", + }) + got, param := resolveThreadsInput(rt) + if got != "omt_canonical" { + t.Fatalf("resolveThreadsInput() = %q, want omt_canonical", got) + } + if param != "--thread" { + t.Fatalf("resolveThreadsInput() param = %q, want --thread (canonical wins)", param) + } +} + +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 len(alias.Enum) != 0 { + // Declared enums are framework-validated before canonical-wins + // resolution, so an inert alias value would fail the command + // even when the canonical flag is present. Value sets for + // aliases are enforced by validateAliasEnum in Validate. + t.Fatalf("--%s (hidden alias) must not declare an Enum, got %v", tt.alias, alias.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) + } +} + +// --- review regressions: error attribution and inert-alias enum handling --- + +// Alias-supplied values must attribute failures to the flag the caller +// actually typed, not to the canonical flag it maps to. +func TestChatMessagesListAliasErrorsNameTypedFlag(t *testing.T) { + rt := newTestRuntimeContext(t, map[string]string{"start-time": "bad-time"}, nil) + _, err := buildChatMessageListRequest(rt, "oc_x") + assertAliasValidationError(t, err, "--start-time", "--start-time: cannot parse time") + + rt = newTestRuntimeContext(t, map[string]string{"end-time": "also-bad"}, nil) + _, err = buildChatMessageListRequest(rt, "oc_x") + assertAliasValidationError(t, err, "--end-time", "--end-time: cannot parse time") +} + +func TestThreadsMessagesListThreadIDAliasErrorNamesTypedFlag(t *testing.T) { + rt := newThreadsTestRT(t, map[string]string{"thread-id": "not-a-thread"}) + err := ImThreadsMessagesList.Validate(context.Background(), rt) + assertAliasValidationError(t, err, "--thread-id", `invalid --thread-id "not-a-thread"`) +} + +func TestMessagesMGetMessageIDAliasErrorNamesTypedFlag(t *testing.T) { + rt := newTestRuntimeContext(t, map[string]string{"message-id": "not-om"}, nil) + err := ImMessagesMGet.Validate(context.Background(), rt) + assertAliasValidationError(t, err, "--message-id", `invalid message ID "not-om"`) +} + +// A hidden alias with an invalid value must be ignored entirely when the +// canonical flag is present (canonical wins), and rejected under its own +// name when it is the flag in effect. +func TestValidateAliasEnum(t *testing.T) { + rt := newTestRuntimeContext(t, map[string]string{"order": "asc", "sort-order": "unexpected"}, nil) + if err := validateAliasEnum(rt, "sort-order", "order", "asc", "desc"); err != nil { + t.Fatalf("inert alias value must not fail the command: %v", err) + } + params, err := buildChatMessageListRequest(rt, "oc_x") + if err != nil { + t.Fatalf("buildChatMessageListRequest() error = %v", err) + } + if got := params["sort_type"][0]; got != "ByCreateTimeAsc" { + t.Fatalf("sort_type = %q, want ByCreateTimeAsc (canonical --order asc wins)", got) + } + + rt = newTestRuntimeContext(t, map[string]string{"sort-order": "unexpected"}, nil) + err = validateAliasEnum(rt, "sort-order", "order", "asc", "desc") + assertAliasValidationError(t, err, "--sort-order", `invalid value "unexpected" for --sort-order, allowed: asc, desc`) + + rt = newTestRuntimeContext(t, map[string]string{"sort-order": "desc"}, nil) + if err := validateAliasEnum(rt, "sort-order", "order", "asc", "desc"); err != nil { + t.Fatalf("valid alias value must pass: %v", err) + } +} 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_mget.go b/shortcuts/im/im_messages_mget.go index bf1a2e0a0e..fd740997fc 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,22 +46,23 @@ var ImMessagesMGet = common.Shortcut{ return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - ids := common.SplitCSV(runtime.Str("message-ids")) + raw, param := resolveMessageIDsInputWithParam(runtime) + ids := common.SplitCSV(raw) if len(ids) == 0 { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids is required (comma-separated om_xxx)").WithParam("--message-ids") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required (comma-separated om_xxx)", param).WithParam(param) } if len(ids) > maxMGetMessageIDs { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids)).WithParam("--message-ids") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s supports at most %d IDs per request (got %d)", param, maxMGetMessageIDs, len(ids)).WithParam(param) } for _, id := range ids { - if _, err := validateMessageID(id); err != nil { + if _, err := validateMessageIDForParam(id, param); err != nil { return err } } 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 +129,17 @@ var ImMessagesMGet = common.Shortcut{ return nil }, } + +func resolveMessageIDsInput(runtime *common.RuntimeContext) string { + ids, _ := resolveMessageIDsInputWithParam(runtime) + return ids +} + +// resolveMessageIDsInputWithParam also reports which flag supplied the value, +// so validation errors are attributed to the flag the caller actually typed. +func resolveMessageIDsInputWithParam(runtime *common.RuntimeContext) (string, string) { + if old, ok := aliasFlagValue(runtime, "message-id", "message-ids"); ok { + return old, "--message-id" + } + return runtime.Str("message-ids"), "--message-ids" +} diff --git a/shortcuts/im/im_messages_resources_download.go b/shortcuts/im/im_messages_resources_download.go index 1327e00f18..4909664a24 100644 --- a/shortcuts/im/im_messages_resources_download.go +++ b/shortcuts/im/im_messages_resources_download.go @@ -30,8 +30,8 @@ var ImMessagesResourcesDownload = common.Shortcut{ AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "message-id", Desc: "message ID (om_xxx)", Required: true}, - {Name: "file-key", Desc: "resource key (img_xxx or file_xxx)", Required: true}, - {Name: "type", Desc: "resource type (image or file)", Required: true, Enum: []string{"image", "file"}}, + {Name: "file-key", Desc: "resource key (img_xxx or file_xxx; required)"}, + {Name: "type", Desc: "resource type (required)", Enum: []string{"image", "file"}}, {Name: "output", Desc: "local save path (relative only, no .. traversal); when omitted, uses the server's Content-Disposition filename if available, otherwise file_key; extension is inferred from Content-Disposition or Content-Type if not provided"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -52,6 +52,9 @@ var ImMessagesResourcesDownload = common.Shortcut{ } else if _, err := validateMessageID(messageId); err != nil { return err } + if err := validateIMResourceDownloadRequiredFlags(runtime.Str("file-key"), runtime.Str("type")); err != nil { + return err + } relPath, err := normalizeDownloadOutputPath(runtime.Str("file-key"), runtime.Str("output")) if err != nil { return err @@ -86,6 +89,33 @@ var ImMessagesResourcesDownload = common.Shortcut{ }, } +const imResourceDownloadRequiredFlagsHint = "get --file-key from message content with `lark-cli im +messages-mget --message-ids om_xxx` (images use img_xxx; files use file_xxx), or download all attachments with `lark-cli im +chat-messages-list --download-resources` without supplying each file key" + +func validateIMResourceDownloadRequiredFlags(fileKey, fileType string) error { + missingFileKey := strings.TrimSpace(fileKey) == "" + missingType := strings.TrimSpace(fileType) == "" + if !missingFileKey && !missingType { + return nil + } + + if missingFileKey && missingType { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-key and --type are required"). + WithParams( + errs.InvalidParam{Name: "--file-key", Reason: "required"}, + errs.InvalidParam{Name: "--type", Reason: "required"}, + ). + WithHint("%s", imResourceDownloadRequiredFlagsHint) + } + if missingFileKey { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-key is required"). + WithParam("--file-key"). + WithHint("%s", imResourceDownloadRequiredFlagsHint) + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required"). + WithParam("--type"). + WithHint("%s", imResourceDownloadRequiredFlagsHint) +} + func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) { fileKey = strings.TrimSpace(fileKey) if fileKey == "" { diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index f714006a7d..5362ea2424 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -19,7 +19,6 @@ import ( const ( messagesSearchDefaultPageSize = 20 - messagesSearchMaxPageSize = 50 messagesSearchDefaultPageLimit = 20 messagesSearchMaxPageLimit = 40 messagesSearchMGetBatchSize = 50 @@ -35,6 +34,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"}}, @@ -45,7 +45,8 @@ 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: "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)"}, @@ -264,6 +265,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") @@ -365,12 +369,13 @@ 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") + pageSizeFlag := "page-size" + if _, ok := aliasIntFlagValue(runtime, "limit", "page-size"); ok { + pageSizeFlag = "limit" } - if pageSize > messagesSearchMaxPageSize { - pageSize = messagesSearchMaxPageSize + pageSize, err := validateIMPageSizeFlag(runtime, "+messages-search", pageSizeFlag, 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..0b1e5d3763 --- /dev/null +++ b/shortcuts/im/im_page_size_limits.go @@ -0,0 +1,65 @@ +// 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 validateIMPageSizeFlag(runtime, command, "page-size", defaultValue) +} + +func validateIMPageSizeFlag(runtime *common.RuntimeContext, command, flagName string, defaultValue int) (int, error) { + return common.ValidatePageSizeTyped( + runtime, + flagName, + 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..43feb8d634 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 --order asc|desc sorting, 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"}, @@ -30,28 +35,36 @@ 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: "page size (1-500)"}, + {Name: "sort", Hidden: true, Desc: "alias of --order (hidden)"}, + {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, }, 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") - 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) @@ -69,29 +82,45 @@ var ImThreadsMessagesList = common.Shortcut{ return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - threadId := runtime.Str("thread") + threadId, threadParam := resolveThreadsInput(runtime) if threadId == "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--thread is required (om_xxx or omt_xxx)").WithParam("--thread") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required (om_xxx or omt_xxx)", threadParam).WithParam(threadParam) } 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") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: must start with om_ or omt_", threadParam, threadId).WithParam(threadParam) + } + if err := validateAliasEnum(runtime, "sort", "order", "asc", "desc"); err != nil { + 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") } - _, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) - return err + return nil }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - threadId, err := resolveThreadID(runtime, runtime.Str("thread")) + pageSize, err := validateIMPageSize(runtime, "+threads-messages-list", threadsMessagesMaxPageSize) + if err != nil { + return err + } + threadInput, _ := resolveThreadsInput(runtime) + threadId, err := resolveThreadID(runtime, threadInput) if err != nil { return err } 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 +191,71 @@ 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 +} + +func resolveThreadsInput(runtime *common.RuntimeContext) (string, string) { + if old, ok := aliasFlagValue(runtime, "thread-id", "thread"); ok { + return old, "--thread-id" // attribute errors to the flag the caller actually typed + } + return runtime.Str("thread"), "--thread" +} + // 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..29bf801da5 100644 --- a/shortcuts/im/sort_flags.go +++ b/shortcuts/im/sort_flags.go @@ -3,16 +3,77 @@ package im -import "github.com/larksuite/cli/shortcuts/common" +import ( + "fmt" + "strings" -// 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 is only for flags with identical semantics and value +// domains; value-aware compatibility such as +chat-search --types stays in +// that command's validation. 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) +} + +// validateAliasEnum enforces the fixed value set of a hidden alias flag, but +// only when the alias is actually in effect (alias set, canonical flag not). +// When the canonical flag is present the alias is ignored entirely — including +// its value — so a stray invalid alias value must not fail the command. The +// enum therefore cannot live on the Flag declaration (the framework validates +// declared enums before canonical-wins resolution runs); each command calls +// this from Validate instead. +func validateAliasEnum(rt *common.RuntimeContext, oldName, newName string, allowed ...string) error { + if !rt.Changed(oldName) || rt.Changed(newName) { + return nil + } + val := rt.Str(oldName) + if val == "" { + return nil + } + for _, a := range allowed { + if val == a { + return nil + } + } + return common.ValidationErrorf("invalid value %q for --%s, allowed: %s", val, oldName, strings.Join(allowed, ", ")). + WithParam("--" + oldName) +} 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/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..c919eae08f 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, --order asc|desc sorting, 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 --order asc|desc sorting, 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..abadaccb05 100644 --- a/skills/lark-im/references/lark-im-chat-members-list.md +++ b/skills/lark-im/references/lark-im-chat-members-list.md @@ -2,7 +2,7 @@ > **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. -List the members of a chat. Users and bots are returned in **separate buckets** — `users[]` and `bots[]` — with per-bucket totals (`user_total` / `bot_total`). Use `--member-types` to return only one kind. +List the members of a chat. Users and bots are returned in **separate buckets** — `users[]` and `bots[]` — with per-bucket totals (`user_total` / `bot_total`). Use `--member-types` to return only one kind. `all` explicitly selects the default unfiltered behavior; plural `users` and `bots` are accepted as `user` and `bot`. This skill maps to the shortcut: `lark-cli im +chat-members-list` (internally calls `GET /open-apis/im/v1/chats/{chat_id}/members/list`). @@ -16,6 +16,9 @@ lark-cli im +chat-members-list --chat-id oc_xxx lark-cli im +chat-members-list --chat-id oc_xxx --member-types user lark-cli im +chat-members-list --chat-id oc_xxx --member-types user,bot +# Explicitly request all member types (same request as omitting --member-types) +lark-cli im +chat-members-list --chat-id oc_xxx --member-types all + # Walk every page (capped by --page-limit; 0 = unlimited) lark-cli im +chat-members-list --chat-id oc_xxx --page-all --page-limit 0 @@ -32,7 +35,7 @@ lark-cli im +chat-members-list --chat-id oc_xxx --dry-run | Parameter | Required | Limits | Description | |------|------|------|------| | `--chat-id ` | Yes | `oc_xxx` | Target chat | -| `--member-types ` | No | `user`, `bot` (comma-separated or repeated) | Member types to return. Omitted = all | +| `--member-types ` | No | `user`, `bot`, `all` (comma-separated or repeated) | Member types to return. Omitted or `all` = no filter. `users` and `bots` are accepted as plural spellings. If `all` appears with another value, no filter is applied | | `--member-id-type ` | No | `open_id` (default), `union_id`, `user_id` | ID type for `member_id` in the response | | `--page-size ` | No | 1-100, default 20 | Results per page. With `--page-all` and no explicit `--page-size`, the max (100) is used automatically to minimize round-trips | | `--page-token ` | No | - | Pagination cursor; **implies a single-page fetch** (disables auto-pagination) | @@ -78,6 +81,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 | -| `--member-types contains invalid value` | value other than `user`/`bot` | | Use `user`, `bot`, or both | +| `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`, `all`, `users`, or `bots` | | Use a supported singular, plural, or `all` | | 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..2133d1587d 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 ``` @@ -39,11 +42,13 @@ 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