feat: add auto-pagination to messages search and update lark-im docs (#30)

Change-Id: Ic50e891d2385c2e3ac902cd89d95c3db99f94050
This commit is contained in:
YangJunzhou-01
2026-03-30 23:00:41 +08:00
committed by GitHub
parent 9b933f1a20
commit 69bcdd9e35
5 changed files with 531 additions and 45 deletions

View File

@@ -447,6 +447,17 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImMessagesSearch invalid page limit", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "incident",
"page-limit": "41",
}, nil)
err := ImMessagesSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--page-limit must be an integer between 1 and 40") {
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
}
})
t.Run("ImMessagesSearch invalid sender id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"sender": "user_1",
@@ -479,6 +490,45 @@ func TestShortcutValidateBranches(t *testing.T) {
})
}
func TestMessagesSearchPaginationConfig(t *testing.T) {
t.Run("default single page", func(t *testing.T) {
runtime := newTestRuntimeContext(t, nil, nil)
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
if autoPaginate {
t.Fatal("messagesSearchPaginationConfig() autoPaginate = true, want false")
}
if pageLimit != messagesSearchDefaultPageLimit {
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want %d", pageLimit, messagesSearchDefaultPageLimit)
}
})
t.Run("page all uses max limit", func(t *testing.T) {
runtime := newTestRuntimeContext(t, nil, map[string]bool{
"page-all": true,
})
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
if !autoPaginate {
t.Fatal("messagesSearchPaginationConfig() autoPaginate = false, want true")
}
if pageLimit != messagesSearchMaxPageLimit {
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want %d", pageLimit, messagesSearchMaxPageLimit)
}
})
t.Run("explicit page limit enables auto pagination", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"page-limit": "3",
}, nil)
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
if !autoPaginate {
t.Fatal("messagesSearchPaginationConfig() autoPaginate = false, want true")
}
if pageLimit != 3 {
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want 3", pageLimit)
}
})
}
func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{

View File

@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -16,6 +17,15 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
messagesSearchDefaultPageSize = 20
messagesSearchMaxPageSize = 50
messagesSearchDefaultPageLimit = 20
messagesSearchMaxPageLimit = 40
messagesSearchMGetBatchSize = 50
messagesSearchChatBatchSize = 50
)
var ImMessagesSearch = common.Shortcut{
Service: "im",
Command: "+messages-search",
@@ -37,6 +47,8 @@ var ImMessagesSearch = common.Shortcut{
{Name: "end", Desc: "end time(ISO 8601) with local timezone offset (e.g. 2026-03-25T23:59:59+08:00)"},
{Name: "page-size", Default: "20", Desc: "page size (1-50)"},
{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)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
req, err := buildMessagesSearchRequest(runtime)
@@ -49,8 +61,14 @@ var ImMessagesSearch = common.Shortcut{
dryParams[k] = vs[0]
}
}
return common.NewDryRunAPI().
Desc("Step 1: search messages").
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
d := common.NewDryRunAPI()
if autoPaginate {
d = d.Desc(fmt.Sprintf("Step 1: search messages (auto-paginates up to %d page(s))", pageLimit))
} else {
d = d.Desc("Step 1: search messages")
}
return d.
POST("/open-apis/im/v1/messages/search").
Params(dryParams).
Body(req.body).
@@ -67,12 +85,10 @@ var ImMessagesSearch = common.Shortcut{
return err
}
searchData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages/search", req.params, req.body)
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
if err != nil {
return err
}
rawItems, _ := searchData["items"].([]interface{})
hasMore, nextPageToken := common.PaginationMeta(searchData)
if len(rawItems) == 0 {
outData := map[string]interface{}{
@@ -99,8 +115,7 @@ var ImMessagesSearch = common.Shortcut{
}
// ── Step 2: Batch fetch message details (mget) ──
mgetURL := buildMGetURL(messageIds)
mgetData, err := runtime.DoAPIJSON(http.MethodGet, mgetURL, nil, nil)
msgItems, err := batchMGetMessages(runtime, messageIds)
if err != nil {
// Fallback when mget fails: return ID list only
outData := map[string]interface{}{
@@ -118,37 +133,22 @@ var ImMessagesSearch = common.Shortcut{
})
return nil
}
msgItems, _ := mgetData["items"].([]interface{})
// ── Step 3: Batch fetch chat info ──
chatIdSet := map[string]bool{}
chatIds := make([]string, 0, len(msgItems))
chatSeen := make(map[string]bool)
for _, item := range msgItems {
m, _ := item.(map[string]interface{})
if chatId, _ := m["chat_id"].(string); chatId != "" {
chatIdSet[chatId] = true
if !chatSeen[chatId] {
chatSeen[chatId] = true
chatIds = append(chatIds, chatId)
}
}
}
chatContexts := map[string]map[string]interface{}{}
if len(chatIdSet) > 0 {
chatIds := make([]string, 0, len(chatIdSet))
for id := range chatIdSet {
chatIds = append(chatIds, id)
}
chatRes, chatErr := runtime.DoAPIJSON(
http.MethodPost, "/open-apis/im/v1/chats/batch_query",
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
map[string]interface{}{"chat_ids": chatIds},
)
if chatErr == nil {
if chatItems, ok := chatRes["items"].([]interface{}); ok {
for _, ci := range chatItems {
cm, _ := ci.(map[string]interface{})
if cid, _ := cm["chat_id"].(string); cid != "" {
chatContexts[cid] = cm
}
}
}
}
if len(chatIds) > 0 {
chatContexts = batchQueryChatContexts(runtime, chatIds)
}
// ── Step 4: Format message content + attach chat context ──
@@ -225,6 +225,9 @@ var ImMessagesSearch = common.Shortcut{
moreHint = " (more available, use --page-token to fetch next page)"
}
fmt.Fprintf(w, "\n%d search result(s)%s\n", len(enriched), moreHint)
if truncatedByLimit {
fmt.Fprintf(w, "warning: stopped after fetching %d page(s); use --page-limit, --page-all, or --page-token to continue\n", pageLimit)
}
})
return nil
},
@@ -247,6 +250,14 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
endFlag := runtime.Str("end")
pageSizeStr := runtime.Str("page-size")
pageToken := runtime.Str("page-token")
pageLimitStr := strings.TrimSpace(runtime.Str("page-limit"))
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
pageLimit, err := strconv.Atoi(pageLimitStr)
if err != nil || pageLimit < 1 || pageLimit > messagesSearchMaxPageLimit {
return nil, output.ErrValidation("--page-limit must be an integer between 1 and 40")
}
}
filter := map[string]interface{}{}
timeRange := map[string]interface{}{}
@@ -322,14 +333,14 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
body["filter"] = filter
}
pageSize := 20
pageSize := messagesSearchDefaultPageSize
if pageSizeStr != "" {
n, err := strconv.Atoi(pageSizeStr)
if err != nil || n < 1 {
return nil, output.ErrValidation("--page-size must be an integer between 1 and 50")
}
if n > 50 {
n = 50
if n > messagesSearchMaxPageSize {
n = messagesSearchMaxPageSize
}
pageSize = n
}
@@ -346,3 +357,124 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
body: body,
}, nil
}
func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginate bool, pageLimit int) {
autoPaginate = runtime.Bool("page-all")
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
autoPaginate = true
}
pageLimit = messagesSearchDefaultPageLimit
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
if n, err := strconv.Atoi(strings.TrimSpace(runtime.Str("page-limit"))); err == nil && n > 0 {
pageLimit = min(n, messagesSearchMaxPageLimit)
}
} else if runtime.Bool("page-all") {
pageLimit = messagesSearchMaxPageLimit
}
return autoPaginate, pageLimit
}
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, error) {
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
pageToken := ""
if tokens := req.params["page_token"]; len(tokens) > 0 {
pageToken = tokens[0]
}
pageSize := strconv.Itoa(messagesSearchDefaultPageSize)
if sizes := req.params["page_size"]; len(sizes) > 0 {
pageSize = sizes[0]
}
var (
allItems []interface{}
lastHasMore bool
lastPageToken string
truncatedByLimit bool
pageCount int
)
for {
pageCount++
params := larkcore.QueryParams{
"page_size": []string{pageSize},
}
if pageToken != "" {
params["page_token"] = []string{pageToken}
}
searchData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
if err != nil {
return nil, false, "", false, pageLimit, err
}
items, _ := searchData["items"].([]interface{})
allItems = append(allItems, items...)
lastHasMore, lastPageToken = common.PaginationMeta(searchData)
if !autoPaginate || !lastHasMore || lastPageToken == "" {
break
}
if pageCount >= pageLimit {
truncatedByLimit = true
break
}
pageToken = lastPageToken
}
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, nil
}
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
var items []interface{}
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
mgetData, err := runtime.DoAPIJSON(http.MethodGet, buildMGetURL(batch), nil, nil)
if err != nil {
return nil, err
}
batchItems, _ := mgetData["items"].([]interface{})
items = append(items, batchItems...)
}
return items, nil
}
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
chatContexts := map[string]map[string]interface{}{}
for _, batch := range chunkStrings(chatIds, messagesSearchChatBatchSize) {
chatRes, chatErr := runtime.DoAPIJSON(
http.MethodPost, "/open-apis/im/v1/chats/batch_query",
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
map[string]interface{}{"chat_ids": batch},
)
if chatErr != nil {
continue
}
if chatItems, ok := chatRes["items"].([]interface{}); ok {
for _, ci := range chatItems {
cm, _ := ci.(map[string]interface{})
if cid, _ := cm["chat_id"].(string); cid != "" {
chatContexts[cid] = cm
}
}
}
}
return chatContexts
}
func chunkStrings(items []string, chunkSize int) [][]string {
if len(items) == 0 || chunkSize <= 0 {
return nil
}
chunks := make([][]string, 0, (len(items)+chunkSize-1)/chunkSize)
for start := 0; start < len(items); start += chunkSize {
end := start + chunkSize
if end > len(items) {
end = len(items)
}
chunks = append(chunks, items[start:end])
}
return chunks
}

View File

@@ -0,0 +1,285 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newMessagesSearchRuntime(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool, rt http.RoundTripper) *common.RuntimeContext {
t.Helper()
runtime := newBotShortcutRuntime(t, rt)
cmd := &cobra.Command{Use: "test"}
stringFlagNames := []string{
"query",
"page-size",
"page-token",
"page-limit",
}
for _, name := range stringFlagNames {
cmd.Flags().String(name, "", "")
}
boolFlagNames := []string{"page-all"}
for _, name := range boolFlagNames {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, value := range stringFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, value := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[value]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
runtime.Cmd = cmd
runtime.Format = "pretty"
return runtime
}
func TestImMessagesSearchExecuteAutoPaginationBatches(t *testing.T) {
var (
searchPageTokens []string
mgetBatchSizes []int
chatBatchSizes []int
)
runtime := newMessagesSearchRuntime(t, map[string]string{
"query": "incident",
"page-limit": "2",
}, map[string]bool{
"page-all": true,
}, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search"):
pageToken := req.URL.Query().Get("page_token")
searchPageTokens = append(searchPageTokens, pageToken)
switch pageToken {
case "":
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": buildSearchResultItems(1, 50),
"has_more": true,
"page_token": "tok_p2",
},
}), nil
case "tok_p2":
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": buildSearchResultItems(51, 55),
"has_more": true,
"page_token": "tok_p3",
},
}), nil
default:
return nil, fmt.Errorf("unexpected search page_token: %q", pageToken)
}
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget"):
ids := req.URL.Query()["message_ids"]
mgetBatchSizes = append(mgetBatchSizes, len(ids))
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": buildMessageDetails(ids),
},
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
var body struct {
ChatIDs []string `json:"chat_ids"`
}
rawBody, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if err := json.Unmarshal(rawBody, &body); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
chatBatchSizes = append(chatBatchSizes, len(body.ChatIDs))
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": buildChatContexts(body.ChatIDs),
},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
}
if !reflect.DeepEqual(searchPageTokens, []string{"", "tok_p2"}) {
t.Fatalf("search page tokens = %#v, want %#v", searchPageTokens, []string{"", "tok_p2"})
}
if !reflect.DeepEqual(mgetBatchSizes, []int{50, 5}) {
t.Fatalf("mget batch sizes = %#v, want %#v", mgetBatchSizes, []int{50, 5})
}
if !reflect.DeepEqual(chatBatchSizes, []int{50, 5}) {
t.Fatalf("chat batch sizes = %#v, want %#v", chatBatchSizes, []int{50, 5})
}
outBuf, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if outBuf == nil {
t.Fatal("stdout buffer missing")
}
output := outBuf.String()
if !strings.Contains(output, "55 search result(s)") {
t.Fatalf("stdout = %q, want search results summary", output)
}
if !strings.Contains(output, "warning: stopped after fetching 2 page(s)") {
t.Fatalf("stdout = %q, want page limit warning", output)
}
}
func TestImMessagesSearchExecuteExplicitPageLimitWithoutPageAll(t *testing.T) {
var searchCalls int
runtime := newMessagesSearchRuntime(t, map[string]string{
"query": "incident",
"page-limit": "2",
}, nil, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search"):
searchCalls++
pageToken := req.URL.Query().Get("page_token")
if searchCalls == 1 {
if pageToken != "" {
return nil, fmt.Errorf("unexpected first page token: %q", pageToken)
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": buildSearchResultItems(1, 1),
"has_more": true,
"page_token": "tok_p2",
},
}), nil
}
if pageToken != "tok_p2" {
return nil, fmt.Errorf("unexpected second page token: %q", pageToken)
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": buildSearchResultItems(2, 2),
"has_more": false,
"page_token": "",
},
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget"):
ids := req.URL.Query()["message_ids"]
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": buildMessageDetails(ids),
},
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
var body struct {
ChatIDs []string `json:"chat_ids"`
}
rawBody, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if err := json.Unmarshal(rawBody, &body); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": buildChatContexts(body.ChatIDs),
},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
}
if searchCalls != 2 {
t.Fatalf("searchCalls = %d, want 2", searchCalls)
}
}
func buildSearchResultItems(start, end int) []interface{} {
items := make([]interface{}, 0, end-start+1)
for i := start; i <= end; i++ {
items = append(items, map[string]interface{}{
"meta_data": map[string]interface{}{
"message_id": fmt.Sprintf("om_%03d", i),
},
})
}
return items
}
func buildMessageDetails(ids []string) []interface{} {
items := make([]interface{}, 0, len(ids))
for _, id := range ids {
suffix := strings.TrimPrefix(id, "om_")
items = append(items, map[string]interface{}{
"message_id": id,
"msg_type": "text",
"create_time": "1710000000",
"chat_id": "oc_" + suffix,
"sender": map[string]interface{}{
"id": "cli_bot",
"name": "Bot",
"sender_type": "bot",
},
"body": map[string]interface{}{
"content": fmt.Sprintf(`{"text":"message %s"}`, suffix),
},
})
}
return items
}
func buildChatContexts(chatIDs []string) []interface{} {
items := make([]interface{}, 0, len(chatIDs))
for _, chatID := range chatIDs {
items = append(items, map[string]interface{}{
"chat_id": chatID,
"chat_mode": "group",
"name": "Chat " + strings.TrimPrefix(chatID, "oc_"),
})
}
return items
}

View File

@@ -63,7 +63,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| [`+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) with bot identity; bot-only; 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; downloads image/file resources by message-id and file-key to a safe relative output path |
| [`+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, enriches results via mget and chats batch_query |
| [`+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 with bot identity; bot-only; 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 |
@@ -136,4 +136,3 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `pins.create` | `im:message.pins:write_only` |
| `pins.delete` | `im:message.pins:write_only` |
| `pins.list` | `im:message.pins:read` |

View File

@@ -6,7 +6,7 @@ Search Feishu messages across conversations. This shortcut automatically perform
> **User identity only** (`--as user`). Bot identity is not supported.
This skill maps to the shortcut: `lark-cli im +messages-search` (internally calls `POST /open-apis/im/v1/messages/search` + `GET /open-apis/im/v1/messages/mget`, then batch-fetches chat context).
This skill maps to the shortcut: `lark-cli im +messages-search` (internally calls `POST /open-apis/im/v1/messages/search` + batched `GET /open-apis/im/v1/messages/mget`, then batch-fetches chat context).
## Commands
@@ -49,6 +49,12 @@ lark-cli im +messages-search --query "test" --format csv
# Pagination
lark-cli im +messages-search --query "test" --page-token <PAGE_TOKEN>
# Auto-pagination across multiple pages
lark-cli im +messages-search --query "test" --page-all --format json
# Auto-pagination with an explicit page cap
lark-cli im +messages-search --query "test" --page-limit 5 --format json
# Preview the request without executing it
lark-cli im +messages-search --query "test" --dry-run
```
@@ -69,6 +75,8 @@ lark-cli im +messages-search --query "test" --dry-run
| `--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-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 |
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
| `--as <identity>` | No | Identity type (defaults to and only supports `user`) |
| `--dry-run` | No | Print the request only, do not execute it |
@@ -85,8 +93,9 @@ The shortcut automatically performs:
1. The **search API** returns matching `message_id` values
2. The **mget API** fetches full message content for those message IDs in batch
3. Chat context lookup is fetched in batch and attached to each message
The user does not need to manage the orchestration manually.
The user does not need to manage the orchestration manually. When search results span multiple pages, the shortcut can also paginate automatically with `--page-all` or `--page-limit`.
### 3. Conversation context is enriched automatically
@@ -115,7 +124,15 @@ Each message in JSON output contains:
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
### 4. Search results contain follow-up clues
### 4. Pagination behavior
- Default behavior is still **single-page**.
- `--page-token` is the manual continuation mechanism when you already have a token from a previous response.
- `--page-all` enables auto-pagination and uses a default cap of **40 pages**.
- `--page-limit <n>` enables auto-pagination with an explicit cap. If you pass `--page-limit` without `--page-all`, auto-pagination is still enabled.
- When auto-pagination stops because of the configured page cap, the response still includes the last `has_more` / `page_token` so you can continue manually.
### 5. Search results contain follow-up clues
In JSON output, each message includes `chat_id` and `thread_id` (when present). Use them with other shortcuts for deeper inspection:
@@ -156,25 +173,28 @@ When the user asks you to summarize work, generate a weekly report, or compile a
### Strategy
1. **Start with targeted filters** — use `--chat-id`, `--sender`, `--start`, `--end` to narrow the scope as much as possible before paginating.
2. **Fetch all pages** — after the first call, check the output for `has_more` and `page_token`. If `has_more` is true, immediately issue the next call with `--page-token <token>`. Repeat until `has_more` is false or the results are clearly sufficient.
2. **Prefer auto-pagination** — for report and summary tasks, use `--page-all --format json` by default. If you need a bounded run, use `--page-limit <n> --format json`.
3. **Accumulate before summarizing** — collect all pages of messages first, then analyze and summarize. Do not summarize after the first page alone — you will miss important context.
4. **Use `--format json`** — JSON output includes `has_more` and `page_token` fields needed for pagination. `pretty` and `table` formats omit these fields and are not suitable for pagination. Note: `pretty` is human-readable (per-message rows); `table` is a flat key-value dump of the response envelope and is not human-readable for message lists.
4. **Fall back to `--page-token` when resuming** — if auto-pagination hits the configured page cap and the response still has `has_more=true`, continue from the returned `page_token`.
5. **Use `--format json`** — JSON output includes `has_more` and `page_token` fields needed for pagination. `pretty` and `table` formats are useful for reading but not for resuming pagination reliably.
### Example: Weekly work summary from a project chat
```bash
# Page 1
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --format json
# Preferred: fetch automatically
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-all --format json
# Page 2 (if has_more is true)
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-token <token_from_page_1> --format json
# If you need to cap the run explicitly
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-limit 5 --format json
# Continue until has_more is false, then summarize all collected messages.
# If the bounded run still returns has_more=true, continue manually
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-token <token_from_previous_run> --format json
```
### Key points
- **Always paginate exhaustively** for summary tasks. A single page of 20-50 messages is usually insufficient for a meaningful work summary.
- Prefer `--page-all`; use `--page-limit` only when you need to bound runtime or output volume.
- If the user does not specify a time range, default to the current week (Monday to today) for weekly reports, or ask for clarification.
- When summarizing, group messages by topic/thread rather than by chronological order for better readability.