Files
larksuite__cli/shortcuts/im/convert_lib/thread.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

284 lines
12 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 convertlib
import (
"fmt"
"net/http"
"sync"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ThreadRepliesPerThread is the default max replies fetched per thread in auto-expand.
const ThreadRepliesPerThread = 50
// ThreadRepliesTotalLimit is the default max total thread replies across all threads.
const ThreadRepliesTotalLimit = 500
// threadRepliesFetchConcurrency caps in-flight per-thread GET /messages calls
// when expanding multiple threads in one shortcut invocation. Each call is a
// per-thread RTT (~1s observed), so a strictly serial loop turns N=10 thread
// roots into ~10s of latency — the same multiplier that motivated the
// reactions enrichment fan-out. GET /messages has no published per-app
// rate-limit anywhere near these levels, so we set this higher than the
// reactions batch_query cap (which sits at 4 to stay well under the
// gateway-layer 50/s + 1000/min explicit ceiling on the reactions endpoint).
const threadRepliesFetchConcurrency = 8
// ExpandThreadReplies fetches and embeds thread replies for messages that contain a thread_id.
// For each unique thread_id found in messages, it fetches up to perThread replies (asc order)
// and attaches them as "thread_replies" on the first outer message that referenced that thread.
// Expansion stops once totalLimit cumulative replies have been allocated across planned fetches.
// nameCache is the shared open_id→name map.
//
// Implementation is two-phase:
//
// 1. Plan + concurrent fetch. Walk messages in order, recording every
// unique thread_id with a fetch limit of perThread (no upfront budget
// deduction — see below). Then dispatch the planned fetches with
// bounded concurrency; each goroutine writes only to its own result
// slot, no shared mutable state besides that slot.
//
// 2. Sequential attach with post-hoc budget enforcement. Walk the planned
// threads in their original first-seen order, accumulating actual
// returned reply counts against totalLimit. When a thread's actual
// replies would push the running total past totalLimit, its reply slice
// is truncated to fit the remaining budget and thread_has_more is set
// on its host so consumers know more replies exist server-side. Threads
// that arrive past a fully-exhausted budget keep their thread_id on the
// host but don't get thread_replies attached (semantically identical to
// the pre-existing serial behavior for over-budget threads). The phase
// stays single-threaded because ResolveSenderNames writes to the shared
// nameCache and FormatMessageItem may trigger merge_forward expansion
// that also touches nameCache.
//
// Budget semantics match the pre-existing serial implementation exactly:
// each thread's actual returned count is what gets deducted from the
// budget, not its planned per-thread ceiling. An earlier draft of this
// refactor allocated the budget against the planned ceiling upfront for
// implementation simplicity, but that silently dropped later threads in
// chats where many threads return well under perThread replies (e.g.
// totalLimit=500 + perThread=50 + 12 short threads of 3 replies each → old
// code attached all 12, planned-allocation code attached only 10). The
// trade-off here is a small amount of server-side over-fetching for
// threads that will end up truncated or dropped — bounded by perThread per
// thread — in exchange for preserving the original "every thread that fits
// gets its data" guarantee.
func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]interface{}, nameCache map[string]string, perThread, totalLimit int) {
ExpandThreadRepliesWithResources(runtime, messages, nameCache, perThread, totalLimit, false)
}
// ExpandThreadRepliesWithResources is ExpandThreadReplies with an explicit
// extractResources gate, threaded through to each reply's formatting so that
// (when on) every reply — including a reply that is itself a merge_forward —
// gets its own resources block. extractResources=false reproduces the original
// behavior exactly.
func ExpandThreadRepliesWithResources(runtime *common.RuntimeContext, messages []map[string]interface{}, nameCache map[string]string, perThread, totalLimit int, extractResources bool) {
if runtime == nil {
return
}
if perThread < 1 {
perThread = 1
}
if perThread > 50 {
perThread = 50
}
if totalLimit <= 0 {
totalLimit = ThreadRepliesTotalLimit
}
// Phase 1a: enumerate every unique thread_id in first-seen order. We
// deliberately do NOT deduct anything from the totalLimit budget here —
// see the godoc above and the Phase 2 truncation step. The first outer
// message referencing a given thread_id is the host that will receive
// the thread_replies attachment, matching the pre-existing behavior
// where duplicates inherited nothing.
type plan struct {
threadID string
limit int
host map[string]interface{}
}
var plans []plan
seen := make(map[string]bool)
for _, msg := range messages {
tid, _ := msg["thread_id"].(string)
if tid == "" || seen[tid] {
continue
}
seen[tid] = true
plans = append(plans, plan{threadID: tid, limit: perThread, host: msg})
}
if len(plans) == 0 {
return
}
// Phase 1b: concurrent fetch. Each goroutine writes only to its own
// results[i] slot, so there is no shared mutable state besides that
// slot. The single-batch fast path skips goroutine setup for clarity
// and to keep "one thread root" behavior identical to the old code.
type result struct {
rawReplies []map[string]interface{}
hasMore bool
err error
}
results := make([]result, len(plans))
if len(plans) == 1 {
items, hasMore, err := fetchThreadReplies(runtime, plans[0].threadID, plans[0].limit)
results[0] = result{rawReplies: items, hasMore: hasMore, err: err}
} else {
sem := make(chan struct{}, threadRepliesFetchConcurrency)
var wg sync.WaitGroup
for i, p := range plans {
// Add before the semaphore acquire — sync.WaitGroup godoc
// recommends Add precede the goroutine-spawning event.
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
items, hasMore, err := fetchThreadReplies(runtime, p.threadID, p.limit)
results[i] = result{rawReplies: items, hasMore: hasMore, err: err}
}()
}
wg.Wait()
}
// Phase 2a-pre: apply the totalLimit budget against actual returned
// counts (not planned ceilings) and trim each result in place. Walking
// in original plan order matches the pre-existing serial behavior so a
// chat with budget-exceeding total replies cuts off at the same thread
// position as the old code. Threads past a fully-drained budget have
// their slice cleared to an empty (non-nil) slice — distinct from a
// fetch error's nil rawReplies — so the attach loop below leaves the
// host alone without flagging thread_replies_error. Threads whose
// actual count crosses the boundary get their slice truncated and
// hasMore flagged so consumers know more exist server-side.
remaining := totalLimit
for i := range plans {
r := &results[i]
if r.err != nil || len(r.rawReplies) == 0 {
continue
}
if remaining <= 0 {
// Budget already drained by earlier threads — discard this
// thread's fetched replies. We over-fetched on the wire (one
// of the explicit trade-offs documented on the function), but
// the user-visible output remains the same as the serial
// implementation, which would never have issued this fetch.
// Empty slice (not nil) so the attach loop treats this like
// "successfully returned no replies", not "fetch failed".
r.rawReplies = r.rawReplies[:0]
continue
}
if len(r.rawReplies) > remaining {
r.rawReplies = r.rawReplies[:remaining]
r.hasMore = true
}
remaining -= len(r.rawReplies)
}
// Phase 2a-merge: collect every (post-truncation) raw reply across all
// threads and pre-fetch merge_forward sub-messages for the ones that
// need it. Without this, a thread reply that is itself a merge_forward
// would trigger another serial GET inside FormatMessageItem —
// re-introducing the same N × RTT stall pattern that Phase 1b just
// removed.
var allRawReplies []interface{}
for i := range plans {
r := results[i]
if len(r.rawReplies) == 0 {
continue
}
for _, raw := range r.rawReplies {
allRawReplies = append(allRawReplies, raw)
}
}
mergePrefetch := PrefetchMergeForwardSubItems(runtime, allRawReplies, nameCache)
// Folder replies get the same treatment: a thread reply that is a folder
// message would otherwise trigger another serial GET per reply inside
// FormatMessageItem. PrefetchFolderChildren bounds the fan-out and caches
// XML keyed by reply message_id for the render loop below.
folderPrefetch := PrefetchFolderChildren(runtime, allRawReplies)
// Phase 2a: format every plan's replies sequentially. FormatMessageItem
// may still touch nameCache for non-merge_forward content types
// (e.g. mention resolution), so this stays single-threaded — concurrent
// writes to nameCache would race.
preparedReplies := make([][]map[string]interface{}, len(plans))
for i, p := range plans {
r := results[i]
if r.err != nil || r.rawReplies == nil {
p.host["thread_replies_error"] = true
continue
}
if len(r.rawReplies) == 0 {
continue
}
replies := make([]map[string]interface{}, 0, len(r.rawReplies))
for _, raw := range r.rawReplies {
replies = append(replies, FormatMessageItemWithFolderPrefetchOpts(raw, runtime, nameCache, mergePrefetch, folderPrefetch, extractResources))
}
preparedReplies[i] = replies
}
// Phase 2b: one batched ResolveSenderNames across all replies from all
// threads. The pre-existing per-thread call pattern would issue a fresh
// contact API request for every thread that introduced a new sender,
// turning N threads into up to N serial contact RTTs even after the
// fetches themselves went parallel. Consolidating into a single call
// resolves every still-missing open_id in one request and lets the
// nameCache absorb the rest.
var combined []map[string]interface{}
for _, replies := range preparedReplies {
combined = append(combined, replies...)
}
if len(combined) > 0 {
ResolveSenderNames(runtime, combined, nameCache)
}
// Phase 2c: attach the (now name-resolved) replies to their hosts.
for i, p := range plans {
replies := preparedReplies[i]
if replies == nil {
continue
}
AttachSenderNames(replies, nameCache)
p.host["thread_replies"] = replies
if results[i].hasMore {
p.host["thread_has_more"] = true
}
}
}
// fetchThreadReplies fetches up to limit replies from a thread (ascending order).
// Returns the raw message items, whether more replies exist beyond the limit,
// and a non-nil error when the API call fails.
func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit int) ([]map[string]interface{}, bool, error) {
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", larkcore.QueryParams{
"container_id_type": []string{"thread"},
"container_id": []string{threadID},
"sort_type": []string{"ByCreateTimeAsc"},
"page_size": []string{fmt.Sprint(limit)},
"card_msg_content_type": []string{"raw_card_content"},
// Opt in to server-side sender names: without it, reply senders that
// appear only inside this thread carry no sender_name and — since there
// is no contact/mention fallback — render as their raw id.
"with_sender_name": []string{"true"},
}, nil)
if err != nil {
return nil, false, fmt.Errorf("fetch thread replies for %s: %w", threadID, err) //nolint:forbidigo // best-effort internal thread fetch; never surfaced as a final shortcut error (ExpandThreadReplies is void)
}
hasMore, _ := data["has_more"].(bool)
rawItems, _ := data["items"].([]interface{})
items := make([]map[string]interface{}, 0, len(rawItems))
for _, raw := range rawItems {
if m, ok := raw.(map[string]interface{}); ok {
items = append(items, m)
}
}
return items, hasMore, nil
}