mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
feat: add auto-pagination to messages search and update lark-im docs (#30)
Change-Id: Ic50e891d2385c2e3ac902cd89d95c3db99f94050
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
285
shortcuts/im/im_messages_search_execute_test.go
Normal file
285
shortcuts/im/im_messages_search_execute_test.go
Normal 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
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user