Files
larksuite__cli/shortcuts/im/im_messages_search.go
xuzhigang 7fd6ef3c07 feat: expand folder children one level in IM message output (#2606)
mget / list / search / thread list 读到 folder 消息时,展开一层子项渲染进
folder 标签(cap-10 + has_more + child_count);sub-folder 不递归只带
child_count 深度提示。converter 支持 prefetch 缓存复用 + 并发安全。

1.改动原因
消息内 folder 附件此前只渲染单行标签,用户看不到内容也无法直接取到
子文件 key 去下载;本次在渲染层展开一层子项,sub-folder 保留 key 供
im files folder 继续展开。

2.影响范围
shortcuts/im 消息渲染链 + lark-im skill 文档

| 文件 | 函数 | 改动前 | 改动后 |
|------|------|--------|--------|
| convert_lib/misc.go | folderConverter.Convert | 单行 <folder/> | 展开一层(cap-10/has_more/child_count),folderWarnf 并发安全告警 |
| convert_lib/content_convert.go | ConvertContext | — | +FolderChildren prefetch 缓存 |
| convert_lib/merge.go/text.go/thread.go | 渲染 | 无展开 | 接入 folder 展开 + prefetch |
| im_*(list/mget/search/threads) | 命令 | — | 单次 prefetch 复用 |
| folder_test.go | 单测 | — | C1-C6 覆盖 |
| skills/lark-im/references/*.md | 文档 | — | folder 展开/下载指引(用 im files folder,非 raw GET) |

3.是否引入测试
是(folder_test.go 渲染单测;convert_lib 全绿)

4.是否申请ACL
不需要
2026-09-04 18:34:52 +08:00

501 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
messagesSearchDefaultPageSize = 20
// POST /open-apis/im/v1/messages/search accepts page_size up to 50.
messagesSearchMaxPageSize = 50
messagesSearchDefaultPageLimit = 20
messagesSearchMaxPageLimit = 40
messagesSearchMGetBatchSize = 50
)
var ImMessagesSearch = common.Shortcut{
Service: "im",
Command: "+messages-search",
Description: "Search messages across chats (supports keyword, sender, time range filters) with user or bot identity; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
Risk: "read",
Scopes: []string{"search:message", "im:message.reactions:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Aliases: []string{"keyword"}, Desc: "search keyword"},
{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"}},
{Name: "chat-type", Desc: "chat type", Enum: []string{"group", "p2p"}},
{Name: "sender-type", Desc: "sender type", Enum: []string{"user", "bot"}},
{Name: "exclude-sender-type", Desc: "exclude sender type", Enum: []string{"user", "bot"}},
{Name: "is-at-me", Type: "bool", Desc: "only messages that @me"},
{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", Aliases: []string{"limit"}, Type: "int", Default: fmt.Sprintf("%d", messagesSearchDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", messagesSearchMaxPageSize)},
{Name: "page-token", Desc: "starting pagination cursor"},
{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)"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
req, err := buildMessagesSearchRequest(runtime)
if err != nil {
return common.NewDryRunAPI().Desc(err.Error())
}
dryParams := make(map[string]interface{}, len(req.params))
for k, vs := range req.params {
if len(vs) > 0 {
dryParams[k] = vs[0]
}
}
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")
}
d = d.
POST("/open-apis/im/v1/messages/search").
Params(dryParams).
Body(req.body).
Desc("Step 2 (if results): GET /open-apis/im/v1/messages/mget?message_ids=... — batch fetch message details (max 50)").
Desc("Step 3 (if results): POST /open-apis/im/v1/chats/batch_query — fetch chat names for context")
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Step 4 (if results): reaction enrichment in batches of up to 20 messages. Pass --no-reactions to skip.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildMessagesSearchRequest(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
req, err := buildMessagesSearchRequest(runtime)
if err != nil {
return err
}
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, notice, err := searchMessages(runtime, req)
if err != nil {
return err
}
if len(rawItems) == 0 {
outData := map[string]interface{}{
"messages": []interface{}{},
"total": 0,
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "No matching messages found.")
})
return nil
}
messageIds := make([]string, 0, len(rawItems))
for _, item := range rawItems {
if itemMap, ok := item.(map[string]interface{}); ok {
if metaData, ok := itemMap["meta_data"].(map[string]interface{}); ok {
if id, ok := metaData["message_id"].(string); ok && id != "" {
messageIds = append(messageIds, id)
}
}
}
}
// ── Step 2: Batch fetch message details (mget) ──
msgItems, err := batchMGetMessages(runtime, messageIds)
if err != nil {
// Fallback when mget fails: return ID list only
outData := map[string]interface{}{
"message_ids": messageIds,
"total": len(messageIds),
"has_more": hasMore,
"page_token": nextPageToken,
"note": "failed to fetch message details, returning ID list only",
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d messages (failed to fetch details):\n", len(messageIds))
for _, id := range messageIds {
fmt.Fprintln(w, " ", id)
}
})
return nil
}
// ── Step 3: Batch fetch chat info ──
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 != "" {
if !chatSeen[chatId] {
chatSeen[chatId] = true
chatIds = append(chatIds, chatId)
}
}
}
chatContexts := map[string]map[string]interface{}{}
if len(chatIds) > 0 {
chatContexts = batchQueryChatContexts(runtime, chatIds)
}
// ── Step 4: Format message content + attach chat context ──
nameCache := make(map[string]string)
// Pre-fetch merge_forward sub-messages concurrently before the per-item
// conversion loop, so N merge_forwards in the search hits don't
// serialize into N × ~1s of stall inside FormatMessageItem. Passing
// nameCache also pre-resolves every sub-item's sender open_id in one
// batched contact API call.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, msgItems, nameCache)
folderPrefetch := convertlib.PrefetchFolderChildren(runtime, msgItems)
enriched := make([]map[string]interface{}, 0, len(msgItems))
for _, item := range msgItems {
m, _ := item.(map[string]interface{})
chatId, _ := m["chat_id"].(string)
// Reuse unified content converter
msg := convertlib.FormatMessageItemWithFolderPrefetchOpts(m, runtime, nameCache, mergePrefetch, folderPrefetch, false)
if chatId != "" {
msg["chat_id"] = chatId
}
if chatCtx, ok := chatContexts[chatId]; ok {
chatMode, _ := chatCtx["chat_mode"].(string)
chatName, _ := chatCtx["name"].(string)
if chatMode == "p2p" {
msg["chat_type"] = "p2p"
if p2pId, _ := chatCtx["p2p_target_id"].(string); p2pId != "" {
msg["chat_partner"] = map[string]interface{}{"open_id": p2pId}
}
} else {
msg["chat_type"] = chatMode
if chatName != "" {
msg["chat_name"] = chatName
}
}
}
enriched = append(enriched, msg)
}
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
convertlib.ResolveSenderNames(runtime, enriched, nameCache)
convertlib.AttachSenderNames(enriched, nameCache)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, enriched)
}
outData := map[string]interface{}{
"messages": enriched,
"total": len(enriched),
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(enriched) == 0 {
fmt.Fprintln(w, "No matching messages found.")
return
}
var rows []map[string]interface{}
for _, msg := range enriched {
row := map[string]interface{}{
"time": msg["create_time"],
"type": msg["msg_type"],
}
if sender, ok := msg["sender"].(map[string]interface{}); ok {
if disp := senderDisplay(sender); disp != "" {
row["sender"] = disp
}
}
if chatName, ok := msg["chat_name"].(string); ok && chatName != "" {
row["chat"] = chatName
} else if chatType, ok := msg["chat_type"].(string); ok && chatType == "p2p" {
row["chat"] = "p2p"
} else if cid, ok := msg["chat_id"].(string); ok {
row["chat"] = cid
}
if content, _ := msg["content"].(string); content != "" {
row["content"] = convertlib.TruncateContent(content, 30)
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
moreHint := ""
if hasMore {
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
},
}
type messagesSearchRequest struct {
params larkcore.QueryParams
body map[string]interface{}
}
func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearchRequest, error) {
query := runtime.Str("query")
chatFlag := runtime.Str("chat-id")
senderFlag := runtime.Str("sender")
includeAttachmentTypeFlag := runtime.Str("include-attachment-type")
chatTypeFlag := runtime.Str("chat-type")
senderTypeFlag := runtime.Str("sender-type")
excludeSenderTypeFlag := runtime.Str("exclude-sender-type")
atChatterIdsFlag := runtime.Str("at-chatter-ids")
startFlag := runtime.Str("start")
endFlag := runtime.Str("end")
pageToken := runtime.Str("page-token")
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
pageLimit := runtime.Int("page-limit")
if pageLimit < 1 || pageLimit > messagesSearchMaxPageLimit {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 40").WithParam("--page-limit")
}
}
filter := map[string]interface{}{}
timeRange := map[string]interface{}{}
var startTs, endTs string
if startFlag != "" {
ts, err := common.ParseTime(startFlag)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
startTs = ts
start := startFlag
timeRange["start_time"] = start
}
if endFlag != "" {
ts, err := common.ParseTime(endFlag, "end")
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
endTs = ts
end := endFlag
timeRange["end_time"] = end
}
if startTs != "" && endTs != "" {
sv, _ := strconv.ParseInt(startTs, 10, 64)
ev, _ := strconv.ParseInt(endTs, 10, 64)
if sv > ev {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start cannot be later than --end")
}
}
if len(timeRange) > 0 {
filter["time_range"] = timeRange
}
if senderTypeFlag != "" && excludeSenderTypeFlag != "" {
if senderTypeFlag == excludeSenderTypeFlag {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--sender-type and --exclude-sender-type cannot be the same value")
}
}
if chatFlag != "" {
for _, chatID := range common.SplitCSV(chatFlag) {
if _, err := common.ValidateChatIDTyped("--chat-id", chatID); err != nil {
return nil, err
}
}
filter["chat_ids"] = common.SplitCSV(chatFlag)
}
if senderFlag != "" {
for _, userID := range common.SplitCSV(senderFlag) {
if _, err := common.ValidateUserIDTyped("--sender", userID); err != nil {
return nil, err
}
}
filter["from_ids"] = common.SplitCSV(senderFlag)
}
if includeAttachmentTypeFlag != "" {
filter["include_attachment_types"] = []string{includeAttachmentTypeFlag}
}
if senderTypeFlag != "" {
filter["from_types"] = []string{senderTypeFlag}
}
if excludeSenderTypeFlag != "" {
filter["exclude_from_types"] = []string{excludeSenderTypeFlag}
}
if chatTypeFlag != "" {
filter["chat_type"] = chatTypeFlag
}
if runtime.Bool("is-at-me") {
filter["is_at_me"] = true
}
if atChatterIdsFlag != "" {
ids := common.SplitCSV(atChatterIdsFlag)
for _, id := range ids {
if _, err := common.ValidateUserIDTyped("--at-chatter-ids", id); err != nil {
return nil, err
}
}
filter["at_chatter_ids"] = ids
}
body := map[string]interface{}{"query": query}
if len(filter) > 0 {
body["filter"] = filter
}
pageSize, err := common.ValidatePageSizeTyped(runtime, "page-size", messagesSearchDefaultPageSize, 1, messagesSearchMaxPageSize)
if err != nil {
return nil, err
}
params := larkcore.QueryParams{
"page_size": []string{strconv.Itoa(pageSize)},
}
if pageToken != "" {
params["page_token"] = []string{pageToken}
}
return &messagesSearchRequest{
params: params,
body: body,
}, nil
}
// messagesSearchPaginationConfig derives auto-pagination mode and page limit.
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") {
pageLimit = min(runtime.Int("page-limit"), messagesSearchMaxPageLimit)
} else if runtime.Bool("page-all") {
pageLimit = messagesSearchMaxPageLimit
}
return autoPaginate, pageLimit
}
// searchMessages fetches message search pages and returns the first server notice.
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, string, 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
notice string
)
for {
pageCount++
params := larkcore.QueryParams{
"page_size": []string{pageSize},
}
if pageToken != "" {
params["page_token"] = []string{pageToken}
}
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
if err != nil {
return nil, false, "", false, pageLimit, "", err
}
if notice == "" {
notice, _ = searchData["notice"].(string)
}
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, notice, nil
}
// batchMGetMessages fetches message details in API-sized batches.
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
var items []interface{}
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
mgetData, err := runtime.DoAPIJSONTyped(http.MethodGet, buildMGetURL(batch), nil, nil)
if err != nil {
return nil, err
}
batchItems, _ := mgetData["items"].([]interface{})
items = append(items, batchItems...)
}
return items, nil
}
// batchQueryChatContexts fetches chat metadata best-effort for message rows.
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
chatContexts := map[string]map[string]interface{}{}
// Best-effort: a failed chunk only loses its own entries.
for _, batch := range chunkStrings(chatIds, chatBatchQuerySize) {
_ = queryChatBatch(runtime, batch, chatContexts)
}
return chatContexts
}
// chunkStrings splits a string slice into fixed-size batches.
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
}