Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions shortcuts/im/im_flag_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
70 changes: 70 additions & 0 deletions shortcuts/im/im_flag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion skills/lark-im/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [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 |
Expand Down
15 changes: 8 additions & 7 deletions skills/lark-im/references/lark-im-flag-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <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
Expand All @@ -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
Expand All @@ -41,8 +41,8 @@ lark-cli im +flag-list --as user --page-all --page-limit 10
|------|------|------|
| `--page-size <n>` | 50 | Range 1-50 (server max is 50) |
| `--page-token <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 <n>` | 20 | Max pages in `--page-all` mode (max 1000) |
| `--page-all` | false | Auto-paginate and merge results, capped by `--page-limit` |
| `--page-limit <n>` | 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 |

Expand All @@ -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 <item_id>`.

## Response Example (Sanitized)
Expand Down
19 changes: 19 additions & 0 deletions tests/cli_e2e/im/flag_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}

Expand Down Expand Up @@ -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")
})
}
Loading