Compare commits

...

8 Commits

Author SHA1 Message Date
shanglei
427ead1aa8 fix(im): attribute alias errors to the typed flag and add live pagination e2e
Three review findings on the alias and pagination work.

Alias-supplied values reported failures under the canonical flag name:
--start-time with an unparseable timestamp came back as error.param
"--start", --thread-id as "--thread", --message-id as "--message-ids".
Agents parse error.param to decide their next action (ERROR_CONTRACT.md),
so the error must name the flag the caller actually typed. Track the
source flag through alias resolution and use it in both the message and
the param; --limit already behaved this way.

Declared enums on hidden alias flags were framework-validated before the
canonical-wins resolution ran, so --order asc --sort-order unexpected
failed on a value the command was going to ignore. Hidden aliases no
longer declare enums; validateAliasEnum enforces the value set from
Validate only when the alias is actually in effect, attributing the
rejection to the alias name. Contract tests now pin that aliases must
not declare enums, with regressions at both unit and runner level.

Live pagination coverage was missing: the four commands gained real
multi-page fetching but only mock unit tests and dry-run e2e existed,
while AGENTS.md requires self-contained live E2E for behavior changes.
TestIM_PageAllLiveWorkflow creates its own chats, messages and thread
replies, walks them with --page-size 1 --page-all, and asserts the
merged result plus the truncation contract (has_more, resume page_token,
stderr incomplete notice) for +chat-messages-list,
+threads-messages-list and +chat-list. +chat-search is covered by unit
and dry-run tests only: freshly created chats are not immediately
searchable, which would make a live assertion flaky.
2026-08-01 16:15:57 +08:00
shanglei
15895b74e1 Merge remote-tracking branch 'origin/main' into feat/agent-affordance-fixes 2026-08-01 15:12:28 +08:00
shanglei
02ed6f02a3 fix(im): validate every member-types value and name flags precisely in docs
Review follow-ups on the member-types and chat-search changes.

normalizeMemberTypes accepted any occurrence of "all" before validating
the remaining values, so an invalid value alongside it (--member-types
admin,all) was silently swallowed into "no filter". Validate every value
first; "all" only widens the filter after the whole list is known to be
well-formed, and the rejection message now names all three accepted
spellings.

The skill index and command descriptions for +chat-messages-list and
+threads-messages-list advertised "sort" while the actual flag is
--order; name the flag exactly so callers do not learn a spelling the
command rejects.

Test tightening from the same review: the canonical-precedence e2e now
passes an alias value that would fail validation (--types p2p) alongside
--chat-modes, proving an explicit canonical flag bypasses alias
validation entirely; rejection-path e2e tests assert the structured
validation metadata (error.type, error.subtype, param names) instead of
message text alone.
2026-08-01 15:10:56 +08:00
shanglei
7a2f6443cc fix(im): accept member type variants and improve resource hints 2026-08-01 14:17:51 +08:00
shanglei
3561753a7d feat(im): handle chat-search types by value 2026-08-01 13:26:23 +08:00
shanglei
09b38a7292 feat(im): accept the flag names callers actually type
Six flags in the im domain are routinely typed under a different name —
--start-time for --start, --thread-id for --thread, --message-id for
--message-ids, --keyword for --query, --sort-order for --order and
--limit for --page-size. The value written alongside them is already
valid in every case; only the name is wrong, so the call fails once and
has to be retried under the canonical name.

Register the eight names as hidden aliases, following the existing
pattern in this package: the canonical flag wins when both are given,
--help and schema keep listing only the canonical name, and a note
naming the canonical flag is written to stderr so callers learn it
instead of settling on the alias. The four aliases that already existed
now emit that note too. Out-of-range values report the flag the caller
actually typed, so --limit 500 is rejected as --limit rather than as
--page-size.

--thread and --message-ids drop their Required declaration and validate
in Validate instead, otherwise cobra rejects the call before an alias
can be resolved.

ParseTime gains "2006-01-02 15:04:05 Z07:00". A space-separated
timestamp with an offset is the most common thing written after
--start-time, and without this format the alias would only turn an
unknown-flag error into a parse error. The format is additive: inputs
that parsed before are unaffected.
2026-08-01 12:24:11 +08:00
liangshuo-1
a8ad44ba13 docs: remove broken Star History chart (#2141) 2026-08-01 11:42:24 +08:00
shanglei
eb0bd8a9ab feat(im): add --page-all to list commands and align page-size limits
Four of the most-used im list commands lacked --page-all while sibling
commands in the same domain had it, so callers that learned the flag on
+messages-search kept passing it to +threads-messages-list and friends
and got "unknown flag". Separately, +threads-messages-list declared a
page-size ceiling of 500 while the server accepts 50, so oversized
values were forwarded and came back as an opaque "field validation
failed" with no indication of which field was wrong.

Add --page-all/--page-limit to +threads-messages-list,
+chat-messages-list, +chat-list and +chat-search, following the existing
+flag-list implementation: pages are capped, has_more and page_token
come from the last fetched page so callers can resume, reaching the cap
with has_more=true reports an incomplete result on stderr, and a
non-advancing page_token stops the loop. Progress goes to stderr; stdout
carries data only. Raw items from every page are merged first, then
message conversion, sender-name resolution, thread expansion, reaction
enrichment and resource download run once over the merged set.

Page-size ceilings for the nine paginated im commands now come from a
single table with a table-driven test, out-of-range values are rejected
locally with a structured validation error that names the limit, and no
HTTP request is issued when validation fails. +chat-members-list moves
off its hand-written bounds check onto the shared validator.

+feed-group-list-item keeps its current ceiling of 50: the public
specification for its endpoint is unavailable, so the value is left
pending confirmation rather than guessed.
2026-07-31 19:12:20 +08:00
47 changed files with 2805 additions and 148 deletions

View File

@@ -310,10 +310,6 @@ lark-cli config risk-control default
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## Contributing
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).

View File

@@ -311,10 +311,6 @@ lark-cli config risk-control default
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## 贡献
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -63,15 +63,26 @@ func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
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 boolFlags {
for name := range stringFlags {
if name == "page-size" || name == "page-limit" {
continue
}
if cmd.Flags().Lookup(name) == nil {
cmd.Flags().String(name, "", "")
}
}
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))

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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).

View File

@@ -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,

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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"},

View File

@@ -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")

View File

@@ -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")

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -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)
}
})
}
}

View File

@@ -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"
}

View File

@@ -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 == "" {

View File

@@ -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{

View File

@@ -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),
)
}

View File

@@ -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)
}
})
}
})
}
}

View File

@@ -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 = "<resolved_thread_id>"
}
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)
}
_, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
return err
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")
}
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).

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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" {

View File

@@ -104,17 +104,17 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [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 |

View File

@@ -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 <field>` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--page-all` | No | - | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
| `--page-limit <n>` | 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 |

View File

@@ -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 <id>` | Yes | `oc_xxx` | Target chat |
| `--member-types <strings>` | No | `user`, `bot` (comma-separated or repeated) | Member types to return. Omitted = all |
| `--member-types <strings>` | 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 <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type for `member_id` in the response |
| `--page-size <n>` | 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 <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"` |

View File

@@ -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 <id>` | One of two | Specify the conversation by its chat_id directly (e.g., group chat `oc_xxx`) |
| `--user-id <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 <time>` | No | Start time (ISO 8601 or date only) |
| `--end <time>` | No | End time (ISO 8601 or date only) |
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
| `--page-size <n>` | No | Page size (default 50, max 50) |
| `--start <time>` | No | Start time (ISO 8601 or date only). `--start-time` is an alias for `--start`; prefer the canonical flag |
| `--end <time>` | No | End time (ISO 8601 or date only). `--end-time` is an alias for `--end`; prefer the canonical flag |
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`). `--sort-order` is an alias for `--order`; prefer the canonical flag |
| `--page-size <n>` | No | Page size (default 50, max 50). `--limit` is an alias for `--page-size`; prefer the canonical flag |
| `--page-token <token>` | No | Pagination token |
| `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
| `--page-limit <n>` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) |
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default; no extra requests when omitted |
@@ -106,12 +111,14 @@ Each message contains:
## Pagination (`has_more` / `page_token`)
`im +chat-messages-list` returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue:
By default, `im +chat-messages-list` fetches one page. It returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue:
```bash
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token <PAGE_TOKEN>
```
Use `--page-all` to fetch and merge multiple pages. `--page-limit` defaults to 10 and accepts values from 1 to 1000. If the command reaches this limit while the output still has `has_more=true`, the result is incomplete; resume with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
You can also fall back to the generic API:
```bash

View File

@@ -33,6 +33,9 @@ lark-cli im +chat-search --query "project" --page-size 10
# Pagination
lark-cli im +chat-search --query "project" --page-token "xxx"
# Fetch multiple pages automatically, up to 10 pages by default
lark-cli im +chat-search --query "project" --page-all
# JSON output
lark-cli im +chat-search --query "project" --format json
@@ -53,12 +56,16 @@ lark-cli im +chat-search --query "project" --dry-run
| `--sort <field>` | No | `create_time`, `update_time`, `member_count` | Sort field (always descending) |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--page-all` | No | - | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
| `--page-limit <n>` | No | 1-1000, default 10 | Maximum pages fetched by `--page-all` |
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
By default, the command fetches one page. With `--page-all`, it fetches and merges subsequent pages up to `--page-limit`. If the limit is reached while the output still has `has_more=true`, the result is incomplete; continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
> **CAUTION:** `--sort` is **always descending** — the search API only ranks the chosen field high-to-low (e.g. `member_count` = most members first). There is no ascending option. If the user asks for "fewest first / ascending / 从少到多", tell them the search API does not support ascending order; any low-to-high view requires re-sorting the fetched page client-side and is not an upstream sort. Do **not** invent values like `member_count_asc` or pass `asc` (they are rejected).
## Output Fields
@@ -121,7 +128,7 @@ lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Today's progress update"
|---------|---------|---------|
| `--query and --member-ids cannot both be empty` | Both were omitted | Provide at least `--query` or `--member-ids` |
| Empty results | No visible chats matched the keyword or filters | Relax the keyword or filters and try again |
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
| `invalid --page-size 101: must be between 1 and 100` | page-size is out of range | Use an integer between 1 and 100 |
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |

View File

@@ -10,7 +10,7 @@ Lists **one page** of the **current user's** feed shortcuts.
- Only **CHAT-type** shortcuts are exposed via OpenAPI today (others in the IDL are not yet whitelisted).
- The shortcut is a **thin one-page wrapper** — there is no built-in auto-pagination. Callers drive their own loop when they actually need to paginate.
- Server-side page size is controlled by the service; in normal use one page usually covers the list.
- Server-side page size is controlled by the service, so this command has no `--page-size` flag; in normal use one page usually covers the list.
- Pagination tokens are opaque. If a token is rejected because the shortcut list changed, restart by omitting `--page-token`.
## Commands

View File

@@ -30,7 +30,7 @@ lark-cli im +messages-mget --message-ids "om_aaa" --dry-run
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--message-ids <ids>` | Yes | At least one, max 50, `om_xxx` format, comma-separated | Message ID list |
| `--message-ids <ids>` | Yes | At least one, max 50, `om_xxx` format, comma-separated | Message ID list. `--message-id` is an alias for `--message-ids`; prefer the canonical flag |
| `--no-reactions` | No | — | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | — | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |

View File

@@ -2,10 +2,14 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
Download image or file resources from a message. Supports **automatic chunked download for large files** using HTTP Range requests. Resources are identified by the combination of `message_id` + `file_key`, both of which come directly from message content returned by `im +chat-messages-list`.
Download image or file resources from a message. Supports **automatic chunked download for large files** using HTTP Range requests. Resources are identified by the combination of `message_id` + `file_key`. For a known message ID, run `lark-cli im +messages-mget --message-ids om_xxx` and read the resource key from the returned message content: images use `img_xxx`, while files use `file_xxx`.
> **Note:** read-only message commands render resource keys in message content, but they do not download binaries automatically. Use this command whenever you need to fetch the actual image/file bytes or save them to a specific path.
To download every attachment from a message result or chat without supplying each `file_key`, use `lark-cli im +chat-messages-list --download-resources`.
There is no `--overwrite` flag. Saving to a path that already exists replaces that file atomically; use a different `--output` path to keep the existing file.
This skill maps to the shortcut: `lark-cli im +messages-resources-download` (internally calls `GET /open-apis/im/v1/messages/{message_id}/resources/{file_key}`).
## Commands
@@ -70,8 +74,8 @@ Different resource markers in message content correspond to different `file_key`
### Scenario: Extract and download an image from a message
```bash
# Step 1: Fetch messages and find one containing an image
lark-cli im +chat-messages-list --chat-id oc_xxx
# Step 1: Fetch the known message and find its image key
lark-cli im +messages-mget --message-ids om_xxx
# In the response you see: { "msg_type": "image", "content": "{\"image_key\":\"img_v3_xxx\"}" }
# Step 2: Download the image

View File

@@ -68,7 +68,7 @@ lark-cli im +messages-search --query "test" --dry-run
| Parameter | Required | Description |
|------|------|------|
| `--query <text>` | No | Search keyword (may be empty when used with other filters) |
| `--query <text>` | No | Search keyword (may be empty when used with other filters). `--keyword` is an alias for `--query`; prefer the canonical flag |
| `--chat-id <id>` | No | Restrict to chat IDs, comma-separated (`oc_xxx,oc_yyy`) |
| `--sender <ids>` | No | Sender open_ids, comma-separated (`ou_xxx`) |
| `--include-attachment-type <type>` | No | Attachment filter: `file` / `image` / `video` / `link` |
@@ -79,7 +79,7 @@ lark-cli im +messages-search --query "test" --dry-run
| `--at-chatter-ids <ids>` | No | Filter by @mentioned user open_ids, comma-separated (`ou_xxx,ou_yyy`). Matched results also include messages that `@all` |
| `--start <time>` | No | Start time with local timezone offset required (e.g. `2026-03-24T00:00:00+08:00`) |
| `--end <time>` | No | End time with local timezone offset required (e.g. `2026-03-25T23:59:59+08:00`) |
| `--page-size <n>` | No | Page size (default 20, range 1-50) |
| `--page-size <n>` | No | Page size (default 20, range 1-50). `--limit` is an alias for `--page-size`; prefer the canonical flag |
| `--page-token <token>` | No | Pagination token for the next page |
| `--page-all` | No | Automatically paginate through all result pages (up to 40 pages) |
| `--page-limit <n>` | No | Max pages to fetch when auto-pagination is enabled (default 20, max 40). Setting it explicitly also enables auto-pagination |

View File

@@ -23,6 +23,9 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-size 20
# Pagination
lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
# Fetch multiple pages automatically, up to 10 pages by default
lark-cli im +threads-messages-list --thread omt_xxx --page-all
# Output format options
lark-cli im +threads-messages-list --thread omt_xxx --format pretty
lark-cli im +threads-messages-list --thread omt_xxx --format table
@@ -39,12 +42,14 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run
| Parameter | Required | Description |
|------|------|------|
| `--thread <id>` | Yes | Thread ID (`om_xxx` or `omt_xxx` format) |
| `--thread <id>` | Yes | Thread ID (`om_xxx` or `omt_xxx` format). `--thread-id` is an alias for `--thread`; prefer the canonical flag |
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
| `--order <order>` | No | Sort order: `asc` (default) / `desc` |
| `--page-size <n>` | No | Number of items per page (default 50, range 1-500) |
| `--page-size <n>` | No | Number of items per page (default 50, range 1-50) |
| `--page-token <token>` | No | Pagination token for the next page |
| `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
| `--page-limit <n>` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) |
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
| `--as <identity>` | No | Identity type: `user` (default) / `bot` |
| `--dry-run` | No | Print the request only, do not execute it |
@@ -61,8 +66,9 @@ Thread messages do not support `start_time` / `end_time` filtering because of Fe
### 3. Pagination (`has_more` / `page_token`)
- When the result includes `has_more=true`, use `page_token` to fetch the next page
- If you need the complete thread, keep paginating; if you only need an overview, the first page is often enough
By default, the command fetches one page. When the result includes `has_more=true`, use `page_token` to fetch the next page, or add `--page-all` to fetch and merge subsequent pages automatically. `--page-limit` defaults to 10 and accepts values from 1 to 1000.
If automatic pagination reaches the limit while the output still has `has_more=true`, the result is incomplete. Continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
### 4. Recommended expansion strategy

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"regexp"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIMChatSearchTypesGroupDryRunMatchesChatModesGroup(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
typesResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--types", "group", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
typesResult.AssertExitCode(t, 0)
canonicalResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--chat-modes", "group", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
canonicalResult.AssertExitCode(t, 0)
require.JSONEq(t, canonicalResult.Stdout, typesResult.Stdout)
require.Equal(t, "default", clie2e.DryRunGet(typesResult.Stdout, "api.0.body.filter.chat_modes.0").String())
require.Equal(t, 1, strings.Count(typesResult.Stderr, "note: --types on +chat-search maps to --chat-modes"))
require.NotContains(t, typesResult.Stdout, "maps to --chat-modes")
}
func TestIMChatSearchCanonicalChatModesWinsOverTypes(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--types", "p2p", "--chat-modes", "topic", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "thread", clie2e.DryRunGet(result.Stdout, "api.0.body.filter.chat_modes.0").String())
require.NotContains(t, result.Stderr, "--types on +chat-search maps")
}
func TestIMChatSearchTypesValidationErrors(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
for _, typesValue := range []string{"p2p", "group,p2p"} {
t.Run(typesValue, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--types", typesValue, "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Empty(t, result.Stdout)
message := gjson.Get(result.Stderr, "error.message").String()
require.Contains(t, message, "service does not support p2p")
require.Contains(t, message, "im +chat-list --types p2p")
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String())
require.Equal(t, "--types", gjson.Get(result.Stderr, "error.param").String())
})
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--types", "xxx", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Empty(t, result.Stdout)
message := gjson.Get(result.Stderr, "error.message").String()
require.Contains(t, message, "--chat-modes (group|topic)")
require.Contains(t, message, "--search-types (private|external|public_joined|public_not_joined)")
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String())
require.Equal(t, "--types", gjson.Get(result.Stderr, "error.param").String())
}
func TestIMChatSearchTypesHiddenFromHelp(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: []string{"im", "+chat-search", "--help"}})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.False(t, regexp.MustCompile(`(?m)^\s+--types(?:\s|$)`).MatchString(result.Stdout), "--types leaked into help:\n%s", result.Stdout)
}

View File

@@ -0,0 +1,216 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"regexp"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIMFlagAliasesDryRun(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
aliasArgs []string
canonicalArgs []string
defaultAs string
notes []string
}{
{
name: "chat messages",
aliasArgs: []string{
"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
"--start-time", "2026-07-27 00:00:00 +08:00",
"--end-time", "1785254400",
"--sort-order", "asc", "--limit", "25", "--no-reactions", "--dry-run",
},
canonicalArgs: []string{
"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
"--start", "2026-07-27 00:00:00 +08:00",
"--end", "1785254400",
"--order", "asc", "--page-size", "25", "--no-reactions", "--dry-run",
},
defaultAs: "bot",
notes: []string{
"note: --start-time is an alias for --start",
"note: --end-time is an alias for --end",
"note: --sort-order is an alias for --order",
"note: --limit is an alias for --page-size",
},
},
{
name: "thread id",
aliasArgs: []string{"im", "+threads-messages-list", "--thread-id", "omt_dryrun", "--no-reactions", "--dry-run"},
canonicalArgs: []string{"im", "+threads-messages-list", "--thread", "omt_dryrun", "--no-reactions", "--dry-run"},
defaultAs: "bot",
notes: []string{"note: --thread-id is an alias for --thread"},
},
{
name: "message id",
aliasArgs: []string{"im", "+messages-mget", "--message-id", "om_dryrun", "--no-reactions", "--dry-run"},
canonicalArgs: []string{"im", "+messages-mget", "--message-ids", "om_dryrun", "--no-reactions", "--dry-run"},
defaultAs: "bot",
notes: []string{"note: --message-id is an alias for --message-ids"},
},
{
name: "message search",
aliasArgs: []string{"im", "+messages-search", "--keyword", "project", "--limit", "30", "--no-reactions", "--dry-run"},
canonicalArgs: []string{"im", "+messages-search", "--query", "project", "--page-size", "30", "--no-reactions", "--dry-run"},
defaultAs: "user",
notes: []string{
"note: --keyword is an alias for --query",
"note: --limit is an alias for --page-size",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aliasResult, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.aliasArgs, DefaultAs: tt.defaultAs})
require.NoError(t, err)
aliasResult.AssertExitCode(t, 0)
canonicalResult, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.canonicalArgs, DefaultAs: tt.defaultAs})
require.NoError(t, err)
canonicalResult.AssertExitCode(t, 0)
require.JSONEq(t, canonicalResult.Stdout, aliasResult.Stdout)
require.NotContains(t, aliasResult.Stdout, "is an alias for")
for _, note := range tt.notes {
require.Equal(t, 1, strings.Count(aliasResult.Stderr, note), "stderr:\n%s", aliasResult.Stderr)
}
})
}
}
func TestIMFlagAliasesHiddenFromHelp(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
command string
aliases []string
}{
{"+chat-messages-list", []string{"start-time", "end-time", "sort-order", "limit"}},
{"+threads-messages-list", []string{"thread-id"}},
{"+messages-mget", []string{"message-id"}},
{"+messages-search", []string{"keyword", "limit"}},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: []string{"im", tt.command, "--help"}})
require.NoError(t, err)
result.AssertExitCode(t, 0)
for _, alias := range tt.aliases {
pattern := regexp.MustCompile(`(?m)^\s+--` + regexp.QuoteMeta(alias) + `(?:\s|$)`)
require.False(t, pattern.MatchString(result.Stdout), "--%s leaked into help:\n%s", alias, result.Stdout)
}
})
}
}
func setFlagAliasDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "alias_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "alias_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
}
// A hidden alias with an invalid value must not fail the command when the
// canonical flag is present — the canonical flag wins and the alias is
// ignored entirely, including its value. This exercises the full runner path
// (declared enums are framework-validated before command Validate runs, so
// alias value sets must not be declared as enums).
func TestIMChatMessagesListCanonicalOrderIgnoresInvalidAliasValue(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
"--order", "asc", "--sort-order", "unexpected", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "ByCreateTimeAsc", clie2e.DryRunGet(result.Stdout, "api.0.params.sort_type").String())
require.NotContains(t, result.Stderr, "alias")
// Alias in effect on its own: the value set is enforced and the error is
// attributed to the alias flag.
rejected, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
"--sort-order", "unexpected", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
rejected.AssertExitCode(t, 2)
require.Empty(t, rejected.Stdout)
require.Equal(t, `invalid value "unexpected" for --sort-order, allowed: asc, desc`, gjson.Get(rejected.Stderr, "error.message").String())
require.Equal(t, "--sort-order", gjson.Get(rejected.Stderr, "error.param").String())
}
// Alias-supplied invalid values must be attributed to the flag the caller
// actually typed — never to the canonical flag it maps to.
func TestIMAliasErrorsNameTheTypedFlag(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
cases := []struct {
name string
args []string
wantParam string
wantMsg string
}{
{
name: "start-time",
args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun", "--start-time", "bad-time", "--dry-run"},
wantParam: "--start-time",
wantMsg: "--start-time: cannot parse time",
},
{
name: "thread-id",
args: []string{"im", "+threads-messages-list", "--thread-id", "not-a-thread", "--dry-run"},
wantParam: "--thread-id",
wantMsg: `invalid --thread-id "not-a-thread"`,
},
{
name: "message-id",
args: []string{"im", "+messages-mget", "--message-id", "not-om", "--dry-run"},
wantParam: "--message-id",
wantMsg: `invalid message ID "not-om"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tc.args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Empty(t, result.Stdout)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tc.wantMsg)
require.Equal(t, tc.wantParam, gjson.Get(result.Stderr, "error.param").String())
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String())
})
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"net/http"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestIM_ListPageAllDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
method string
path string
}{
{
name: "chat-messages-list",
args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun"},
method: http.MethodGet,
path: "/open-apis/im/v1/messages",
},
{
name: "threads-messages-list",
args: []string{"im", "+threads-messages-list", "--thread", "omt_dryrun"},
method: http.MethodGet,
path: "/open-apis/im/v1/messages",
},
{
name: "chat-list",
args: []string{"im", "+chat-list"},
method: http.MethodGet,
path: "/open-apis/im/v1/chats",
},
{
name: "chat-search",
args: []string{"im", "+chat-search", "--query", "team"},
method: http.MethodPost,
path: "/open-apis/im/v2/chats/search",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
args := append([]string{}, tc.args...)
args = append(args, "--page-all", "--page-limit", "3", "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, tc.method, clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, tc.path, clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "Auto-paginates through all pages (capped by --page-limit when > 0)", clie2e.DryRunGet(out, "description").String(), "stdout:\n%s", out)
})
}
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"regexp"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIMChatMembersListMemberTypesCompatibilityDryRun(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
run := func(t *testing.T, value string) *clie2e.Result {
t.Helper()
args := []string{"im", "+chat-members-list", "--chat-id", "oc_dryrun"}
if value != "" {
args = append(args, "--member-types", value)
}
args = append(args, "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 0)
return result
}
omitted := run(t, "")
for _, value := range []string{"all", "ALL", "all,user"} {
t.Run(value, func(t *testing.T) {
result := run(t, value)
require.JSONEq(t, omitted.Stdout, result.Stdout)
require.Contains(t, result.Stderr, "means no filter (same as omitting the flag)")
require.NotContains(t, result.Stdout, "means no filter")
})
}
for _, tc := range []struct {
compat string
canonical string
wantNote string
}{
{compat: "users", canonical: "user", wantNote: `note: --member-types "users" is accepted as "user"`},
{compat: "bots", canonical: "bot", wantNote: `note: --member-types "bots" is accepted as "bot"`},
{compat: "Users", canonical: "user", wantNote: `note: --member-types "Users" is accepted as "user"`},
} {
t.Run(tc.compat, func(t *testing.T) {
result := run(t, tc.compat)
canonical := run(t, tc.canonical)
require.JSONEq(t, canonical.Stdout, result.Stdout)
require.Equal(t, 1, strings.Count(result.Stderr, tc.wantNote))
require.NotContains(t, result.Stdout, "is accepted as")
})
}
invalid, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-members-list", "--chat-id", "oc_dryrun", "--member-types", "xxx", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
invalid.AssertExitCode(t, 2)
require.Empty(t, invalid.Stdout)
require.Equal(t, `invalid --member-types value "xxx": expected one of user, bot, all`, gjson.Get(invalid.Stderr, "error.message").String())
require.Equal(t, "validation", gjson.Get(invalid.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(invalid.Stderr, "error.subtype").String())
require.Equal(t, "--member-types", gjson.Get(invalid.Stderr, "error.param").String())
}
func TestIMMessagesResourcesDownloadRequiredFlagsDryRun(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
missing, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-resources-download", "--message-id", "om_dryrun", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
missing.AssertExitCode(t, 2)
require.Empty(t, missing.Stdout)
require.Equal(t, "--file-key and --type are required", gjson.Get(missing.Stderr, "error.message").String())
hint := gjson.Get(missing.Stderr, "error.hint").String()
require.Contains(t, hint, "+messages-mget")
require.Contains(t, hint, "--download-resources")
require.Equal(t, int64(2), gjson.Get(missing.Stderr, "error.params.#").Int())
require.Equal(t, "validation", gjson.Get(missing.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(missing.Stderr, "error.subtype").String())
require.Equal(t, "--file-key", gjson.Get(missing.Stderr, "error.params.0.name").String())
require.Equal(t, "--type", gjson.Get(missing.Stderr, "error.params.1.name").String())
complete, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+messages-resources-download", "--message-id", "om_dryrun",
"--file-key", "img_dryrun", "--type", "image", "--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
complete.AssertExitCode(t, 0)
require.Equal(t, "GET", clie2e.DryRunGet(complete.Stdout, "api.0.method").String())
require.Equal(t, "/open-apis/im/v1/messages/om_dryrun/resources/img_dryrun", clie2e.DryRunGet(complete.Stdout, "api.0.url").String())
require.Equal(t, "image", clie2e.DryRunGet(complete.Stdout, "api.0.params.type").String())
}
func TestIMMessagesResourcesDownloadHelpMarksManualRequiredFlags(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: []string{"im", "+messages-resources-download", "--help"}})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.True(t, regexp.MustCompile(`(?m)^\s+--file-key\s+string\s+.*required`).MatchString(result.Stdout), "--file-key help does not mark it required:\n%s", result.Stdout)
require.True(t, regexp.MustCompile(`(?m)^\s+--type\s+string\s+.*required`).MatchString(result.Stdout), "--type help does not mark it required:\n%s", result.Stdout)
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_PageAllLiveWorkflow exercises the real multi-page pagination added to
// the im list shortcuts: pages are fetched until exhaustion or --page-limit,
// merged in order, and the merged result carries has_more plus the resume
// page_token from the last fetched page.
//
// Self-contained: creates its own chats and messages. Chat cleanup follows the
// repo-wide convention in createChat — lark-cli has no chat-delete command, so
// created chats are intentionally left in the test account.
//
// +chat-search pagination is intentionally not covered live: newly created
// chats are not immediately searchable (server-side indexing lag), which would
// make the assertion flaky. Its pagination loop is covered by unit and dry-run
// tests.
func TestIM_PageAllLiveWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatID := createChat(t, parentT, ctx, "lark-cli-e2e-page-all-"+suffix)
// A second chat guarantees the bot is a member of at least two chats, so
// +chat-list with --page-size 1 is guaranteed to have a second page.
createChat(t, parentT, ctx, "lark-cli-e2e-page-all-b-"+suffix)
texts := make([]string, 0, 3)
var parentMessageID string
for i := 1; i <= 3; i++ {
text := fmt.Sprintf("lark-cli-e2e-page-all-msg-%d-%s", i, suffix)
texts = append(texts, text)
id := sendMessage(t, ctx, chatID, text)
if i == 1 {
parentMessageID = id
}
}
t.Run("chat-messages-list stops at page limit with resume token", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-messages-list", "--chat-id", chatID,
"--page-size", "1", "--page-all", "--page-limit", "1"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.messages.#").Int())
require.True(t, gjson.Get(result.Stdout, "data.has_more").Bool(),
"3 messages at page-size 1 must not fit in one page")
require.NotEmpty(t, gjson.Get(result.Stdout, "data.page_token").String(),
"an incomplete merged result must carry the resume token")
require.Contains(t, result.Stderr, "result is incomplete")
})
t.Run("chat-messages-list walks every page", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-messages-list", "--chat-id", chatID,
"--page-size", "1", "--page-all"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.GreaterOrEqual(t, gjson.Get(result.Stdout, "data.messages.#").Int(), int64(3))
require.False(t, gjson.Get(result.Stdout, "data.has_more").Bool())
require.Contains(t, result.Stderr, "page 2:", "expected a real second page fetch")
for _, text := range texts {
require.Contains(t, result.Stdout, text, "merged result must contain every sent message")
}
})
t.Run("threads-messages-list walks a real thread", func(t *testing.T) {
for i := 1; i <= 2; i++ {
reply, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", parentMessageID,
"--text", fmt.Sprintf("lark-cli-e2e-page-all-reply-%d-%s", i, suffix),
"--reply-in-thread",
},
DefaultAs: "bot",
})
require.NoError(t, err)
reply.AssertExitCode(t, 0)
reply.AssertStdoutStatus(t, true)
}
// Thread replies replicate asynchronously; retry until both are visible.
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"im", "+threads-messages-list", "--thread", parentMessageID,
"--page-size", "1", "--page-all"},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
return strings.Count(result.Stdout, "lark-cli-e2e-page-all-reply-") < 2
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.GreaterOrEqual(t, gjson.Get(result.Stdout, "data.messages.#").Int(), int64(2))
require.False(t, gjson.Get(result.Stdout, "data.has_more").Bool())
require.Contains(t, result.Stderr, "page 2:", "expected a real second page fetch")
})
t.Run("chat-list paginates across chats", func(t *testing.T) {
partial, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-list", "--page-size", "1", "--page-all", "--page-limit", "1"},
DefaultAs: "bot",
})
require.NoError(t, err)
partial.AssertExitCode(t, 0)
partial.AssertStdoutStatus(t, true)
require.Equal(t, int64(1), gjson.Get(partial.Stdout, "data.chats.#").Int())
require.True(t, gjson.Get(partial.Stdout, "data.has_more").Bool(),
"the bot is in at least two chats, so page 1 of size 1 must not be the end")
require.NotEmpty(t, gjson.Get(partial.Stdout, "data.page_token").String())
require.Contains(t, partial.Stderr, "result is incomplete")
// The bot may be a member of many accumulated e2e chats, so a full walk
// can legitimately end at the default --page-limit with has_more=true.
// Assert the merge itself plus the resume contract instead of exhaustion.
full, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-list", "--page-size", "1", "--page-all"},
DefaultAs: "bot",
})
require.NoError(t, err)
full.AssertExitCode(t, 0)
full.AssertStdoutStatus(t, true)
require.GreaterOrEqual(t, gjson.Get(full.Stdout, "data.chats.#").Int(), int64(2))
require.Contains(t, full.Stderr, "page 2:", "expected a real second page fetch")
if gjson.Get(full.Stdout, "data.has_more").Bool() {
require.NotEmpty(t, gjson.Get(full.Stdout, "data.page_token").String(),
"a truncated merged result must carry the resume token")
}
})
}