diff --git a/shortcuts/im/im_flag_list.go b/shortcuts/im/im_flag_list.go index d4761e1246..9a0c52970f 100644 --- a/shortcuts/im/im_flag_list.go +++ b/shortcuts/im/im_flag_list.go @@ -27,8 +27,8 @@ var ImFlagList = common.Shortcut{ Flags: []common.Flag{ {Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"}, {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)"}, + {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)"}, {Name: "enrich-feed-thread", Type: "bool", Default: "true", Desc: "fetch message content for feed-type thread entries (default true; may call messages/mget and require im:message.group_msg:get_as_user/im:message.p2p_msg:get_as_user; use --enrich-feed-thread=false to avoid extra scopes)"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -278,6 +278,10 @@ func executeListAllPages(rt *common.RuntimeContext) error { fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n") break } + if page+1 >= maxPages { + fmt.Fprintf(rt.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 } diff --git a/shortcuts/im/im_flag_test.go b/shortcuts/im/im_flag_test.go index 8a1a31e6b1..d370c4cde6 100644 --- a/shortcuts/im/im_flag_test.go +++ b/shortcuts/im/im_flag_test.go @@ -1536,6 +1536,9 @@ func TestExecuteListAllPages(t *testing.T) { if callCount != 2 { t.Fatalf("expected 2 API calls, got %d", callCount) } + if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); strings.Contains(stderr, "reached page limit") { + t.Fatalf("natural pagination completion must not warn about a page limit, got %q", stderr) + } } func TestExecuteListAllPages_EnrichFeedThread(t *testing.T) { @@ -1625,6 +1628,73 @@ func TestExecuteListAllPages_PageLimit(t *testing.T) { if callCount != 3 { t.Fatalf("expected 3 API calls (page limit), got %d", callCount) } + stderr := rt.IO().ErrOut.(*bytes.Buffer).String() + for _, want := range []string{"reached page limit (3)", "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) + } + } + if strings.Contains(stderr, "token_3") { + t.Fatalf("stderr must not expose the continuation token, got %q", stderr) + } + + var envelope map[string]any + if err := json.Unmarshal(rt.IO().Out.(*bytes.Buffer).Bytes(), &envelope); err != nil { + t.Fatalf("decode stdout: %v", err) + } + data, _ := envelope["data"].(map[string]any) + if hasMore, _ := data["has_more"].(bool); !hasMore { + t.Fatalf("has_more = %#v, want true for an incomplete result", data["has_more"]) + } + if pageToken, _ := data["page_token"].(string); pageToken != "token_3" { + t.Fatalf("page_token = %q, want token_3", pageToken) + } + if _, exists := data["truncated"]; exists { + t.Fatalf("output schema must remain unchanged; unexpected truncated field in %#v", data) + } +} + +func TestExecuteListAllPages_RepeatedTokenDoesNotReportPageLimit(t *testing.T) { + callCount := 0 + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/open-apis/im/v1/flags") { + callCount++ + return shortcutJSONResponse(200, map[string]any{ + "code": 0, + "data": map[string]any{ + "flag_items": []any{}, + "delete_flag_items": []any{}, + "messages": []any{}, + "has_more": true, + "page_token": "same_token", + }, + }), nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + })) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 50, "") + cmd.Flags().Int("page-limit", 10, "") + cmd.Flags().Bool("enrich-feed-thread", false, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + setRuntimeField(t, rt, "Cmd", cmd) + + if err := executeListAllPages(rt); err != nil { + t.Fatalf("executeListAllPages() error = %v", err) + } + if callCount != 2 { + t.Fatalf("API calls = %d, want 2 before repeated-token stop", callCount) + } + stderr := rt.IO().ErrOut.(*bytes.Buffer).String() + if !strings.Contains(stderr, "page_token did not change") { + t.Fatalf("stderr = %q, want non-advancing token warning", stderr) + } + if strings.Contains(stderr, "reached page limit") { + t.Fatalf("stderr = %q, repeated token must not be reported as a page-limit stop", stderr) + } } func TestExecuteListAllPages_APIError(t *testing.T) { diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 6253c4f04d..bc0b363e60 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -117,7 +117,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 | [`+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 | | [`+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; supports `--page-all` auto-pagination | +| [`+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 | | [`+feed-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts; user-only; oc_xxx chat IDs only; batch up to 10 per call; `--head`/`--tail` controls insertion order; partial failures return an `ok:false` ledger | | [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts; user-only; batch up to 10 per call; removing an absent shortcut is idempotent success; real per-item failures return an `ok:false` ledger | | [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts; user-only; omit `--page-token` for the first page; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope | diff --git a/skills/lark-im/references/lark-im-flag-list.md b/skills/lark-im/references/lark-im-flag-list.md index 913a890560..1f17654331 100644 --- a/skills/lark-im/references/lark-im-flag-list.md +++ b/skills/lark-im/references/lark-im-flag-list.md @@ -6,9 +6,9 @@ This skill maps to shortcut: `lark-cli im +flag-list`. Underlying API: `GET /ope ## Sorting Rules (Important) -The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When `has_more=true`, you cannot simply take the first page's items as the latest flags — you must paginate through all pages and take the last item on the last page as the newest. +The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When `has_more=true`, continue pagination until `has_more=false`; only then is the last item in the merged result authoritative as the newest flag. If pagination stops while `has_more=true`, the last item is only the newest observed flag. -Recommended: use `--page-all` for auto-pagination to get the complete list, then use `-q '.data.flag_items[-1]'` to get the latest item. +`--page-all` enables automatic pagination but is still capped by `--page-limit`. The default cap is 20 pages; **20 is not the hard maximum**. Set `--page-limit` between 1 and 1000 when a larger scan is required. A response with `has_more=true` is incomplete, even when `flag_items` is empty; increase the limit or resume from the returned `page_token` before reporting an authoritative latest item or count. ## Commands @@ -19,7 +19,7 @@ lark-cli im +flag-list --as user # Manual pagination with custom page size lark-cli im +flag-list --as user --page-size 30 --page-token -# Auto-paginate to get all flags (recommended) +# Auto-paginate, capped at the default 20 pages lark-cli im +flag-list --as user --page-all # Auto-paginate + get the latest flag @@ -31,8 +31,8 @@ lark-cli im +flag-list --as user --page-all -q '.data.flag_items[].item_id' # Disable auto-enrichment of message content (enabled by default) lark-cli im +flag-list --as user --page-all --enrich-feed-thread=false -# Limit max pages (default 20, max 1000) -lark-cli im +flag-list --as user --page-all --page-limit 10 +# Use the largest supported page limit for a broader scan +lark-cli im +flag-list --as user --page-all --page-limit 1000 ``` ## Parameters @@ -41,8 +41,8 @@ lark-cli im +flag-list --as user --page-all --page-limit 10 |------|------|------| | `--page-size ` | 50 | Range 1-50 (server max is 50) | | `--page-token ` | empty | Pagination token from previous page; empty string must still be provided | -| `--page-all` | false | Auto-paginate to fetch all pages and merge results | -| `--page-limit ` | 20 | Max pages in `--page-all` mode (max 1000) | +| `--page-all` | false | Auto-paginate and merge results, capped by `--page-limit` | +| `--page-limit ` | 20 | Max pages in `--page-all` mode; configurable range 1-1000 (20 is only the default) | | `--enrich-feed-thread` | true | Auto-enrich feed-layer thread entries with message content (calls `im.messages.mget`) | | `--as user` | Required | Currently only supports user identity | @@ -62,6 +62,7 @@ Note: `(thread, feed)` / `(msg_thread, feed)` entries are automatically enriched ## Limitations +- **Auto-pagination is bounded**: `--page-all` fetches at most 20 pages by default. If the response still has `has_more=true`, the result is incomplete; increase `--page-limit` up to 1000 or resume with `page_token`. Never interpret `flag_items: []` as an authoritative zero while more pages remain. Historical `delete_flag_items` may occupy early pages and push active flags to later pages. - **delete_flag_items are not enriched**: Message content is only fetched for active flags (`flag_items`), not canceled flags (`delete_flag_items`). If you need message content for a canceled flag, query the message separately using `+messages-mget --message-ids `. ## Response Example (Sanitized) diff --git a/tests/cli_e2e/im/flag_workflow_test.go b/tests/cli_e2e/im/flag_workflow_test.go index be115c72db..89ebf8052b 100644 --- a/tests/cli_e2e/im/flag_workflow_test.go +++ b/tests/cli_e2e/im/flag_workflow_test.go @@ -240,6 +240,9 @@ func TestIM_FlagListPaginationAsUser(t *testing.T) { require.NoError(t, err) result.AssertExitCode(t, 0) result.AssertStdoutStatus(t, true) + if gjson.Get(result.Stdout, "data.has_more").Bool() { + require.Contains(t, result.Stderr, "reached page limit (3)", "stderr:\n%s", result.Stderr) + } }) } @@ -302,4 +305,20 @@ func TestIM_FlagDryRun(t *testing.T) { require.Contains(t, result.Stdout, "/open-apis/im/v1/flags") require.Contains(t, result.Stdout, "page_size") }) + + t.Run("list flag dry-run accepts hard maximum page limit", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+flag-list", + "--page-all", + "--page-limit", "1000", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Contains(t, result.Stdout, "/open-apis/im/v1/flags") + }) }