mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
8 Commits
feat/lark-
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
427ead1aa8 | ||
|
|
15895b74e1 | ||
|
|
02ed6f02a3 | ||
|
|
7a2f6443cc | ||
|
|
3561753a7d | ||
|
|
09b38a7292 | ||
|
|
a8ad44ba13 | ||
|
|
eb0bd8a9ab |
@@ -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
|
||||
|
||||
[](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).
|
||||
|
||||
@@ -311,10 +311,6 @@ lark-cli config risk-control default
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
377
shortcuts/im/im_flag_aliases_test.go
Normal file
377
shortcuts/im/im_flag_aliases_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
495
shortcuts/im/im_list_page_all_test.go
Normal file
495
shortcuts/im/im_list_page_all_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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{
|
||||
|
||||
65
shortcuts/im/im_page_size_limits.go
Normal file
65
shortcuts/im/im_page_size_limits.go
Normal 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),
|
||||
)
|
||||
}
|
||||
116
shortcuts/im/im_page_size_limits_test.go
Normal file
116
shortcuts/im/im_page_size_limits_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
105
tests/cli_e2e/im/im_chat_search_types_dryrun_test.go
Normal file
105
tests/cli_e2e/im/im_chat_search_types_dryrun_test.go
Normal 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)
|
||||
}
|
||||
216
tests/cli_e2e/im/im_flag_aliases_dryrun_test.go
Normal file
216
tests/cli_e2e/im/im_flag_aliases_dryrun_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
71
tests/cli_e2e/im/im_list_page_all_dryrun_test.go
Normal file
71
tests/cli_e2e/im/im_list_page_all_dryrun_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
155
tests/cli_e2e/im/im_page_all_live_test.go
Normal file
155
tests/cli_e2e/im/im_page_all_live_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user