Skip to content
1 change: 1 addition & 0 deletions shortcuts/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions shortcuts/common/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
26 changes: 19 additions & 7 deletions shortcuts/im/builders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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, "", "")
Expand Down Expand Up @@ -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)
}
})
Expand Down Expand Up @@ -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)
}
})
Expand Down Expand Up @@ -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))
Expand Down
6 changes: 3 additions & 3 deletions shortcuts/im/coverage_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions shortcuts/im/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
76 changes: 76 additions & 0 deletions shortcuts/im/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
100 changes: 90 additions & 10 deletions shortcuts/im/im_chat_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
"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
Expand All @@ -41,18 +45,20 @@
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"},
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort", 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.
Expand All @@ -65,15 +71,25 @@
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

Check warning on line 92 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L92

Added line #L92 was not covered by tests
}
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
Expand All @@ -85,7 +101,7 @@
}
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
Expand All @@ -97,7 +113,13 @@
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
}
Expand Down Expand Up @@ -197,6 +219,64 @@
},
}

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

Check warning on line 229 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L229

Added line #L229 was not covered by tests
}
if maxPages > chatListMaximumPageLimit {
maxPages = chatListMaximumPageLimit

Check warning on line 232 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L232

Added line #L232 was not covered by tests
}

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

Check warning on line 248 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L248

Added line #L248 was not covered by tests
}
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{}{}

Check warning on line 272 in shortcuts/im/im_chat_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/im_chat_list.go#L272

Added line #L272 was not covered by tests
}
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,
Expand Down
Loading
Loading