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

453 lines
16 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 (
"encoding/json"
"fmt"
"math"
"net/url"
"reflect"
"strconv"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)
// ContentConverter defines the interface for converting a message type's raw content to human-readable text.
type ContentConverter interface {
Convert(ctx *ConvertContext) string
}
// ConvertContext holds all context needed for content conversion.
type ConvertContext struct {
RawContent string
MentionMap map[string]string
// Mentions is the raw mentions array from the API response.
// Used by interactive card converter to resolve @user references via mention_key.
Mentions []interface{}
// MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API.
// For other message types these can be zero values.
MessageID string
Runtime *common.RuntimeContext
// SenderNames is a shared cache of open_id -> display name, accumulated across messages
// to avoid redundant contact API calls. May be nil.
SenderNames map[string]string
// MergeForwardSubItems is an optional pre-fetched cache of merge_forward
// sub-message lists, keyed by merge_forward message_id. When set, the
// merge_forward converter uses the cached entry instead of issuing its
// own GET; populated by callers via PrefetchMergeForwardSubItems before
// the FormatMessageItem loop. nil means "no prefetch — fall back to the
// per-message inline GET", which keeps non-shortcut callers (events,
// ad-hoc tests) working unchanged.
MergeForwardSubItems map[string][]map[string]interface{}
// FolderChildren is an optional pre-fetched cache of folder-expansion XML,
// keyed by folder-message message_id. When set, the folder converter uses
// the cached entry instead of issuing its own GET; populated by callers
// via PrefetchFolderChildren before the FormatMessageItem loop. nil means
// "no prefetch — fall back to the per-message inline GET".
FolderChildren map[string]string
}
// converters maps message types to their ContentConverter implementations.
var converters map[string]ContentConverter
func init() {
converters = map[string]ContentConverter{
"text": textConverter{},
"post": postConverter{},
"image": imageConverter{},
"file": fileConverter{},
"audio": audioMsgConverter{},
"video": videoMsgConverter{},
"media": videoMsgConverter{},
"sticker": stickerConverter{},
"interactive": interactiveConverter{},
"share_chat": shareChatConverter{},
"share_user": shareUserConverter{},
"location": locationConverter{},
"merge_forward": mergeForwardConverter{},
"folder": folderConverter{},
"share_calendar_event": calendarEventConverter{},
"calendar": calendarInviteConverter{},
"general_calendar": generalCalendarConverter{},
"video_chat": videoChatConverter{},
"system": systemConverter{},
"todo": todoConverter{},
"vote": voteConverter{},
"hongbao": hongbaoConverter{},
}
}
// ConvertBodyContent converts body.content (a raw JSON string) to human-readable text.
func ConvertBodyContent(msgType string, ctx *ConvertContext) string {
if ctx.RawContent == "" {
return ""
}
if c, ok := converters[msgType]; ok {
return c.Convert(ctx)
}
return fmt.Sprintf("[%s]", msgType)
}
// FormatEventMessage converts an event-pushed message to a human-readable map.
// Event messages have a different structure from API responses:
// - message_type (not msg_type), content is a direct JSON string (not under body.content)
// - mentions are nested under message.mentions
//
// This is the entry point for im.message.receive_v1 event processors.
func FormatEventMessage(msgType, rawContent, messageID string, mentions []interface{}) map[string]interface{} {
content := ConvertBodyContent(msgType, &ConvertContext{
RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions),
Mentions: mentions,
MessageID: messageID,
})
msg := map[string]interface{}{
"msg_type": msgType,
"content": content,
}
if len(mentions) > 0 {
simplified := make([]map[string]interface{}, 0, len(mentions))
for _, raw := range mentions {
item, _ := raw.(map[string]interface{})
key, _ := item["key"].(string)
name, _ := item["name"].(string)
simplified = append(simplified, map[string]interface{}{
"key": key,
"id": extractMentionOpenId(item["id"]),
"name": name,
})
}
msg["mentions"] = simplified
}
return msg
}
// FormatMessageItem converts a raw API message item to a human-readable map.
// senderNames is an optional shared cache (open_id -> name) accumulated across messages;
// pass nil to disable sender name caching.
func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, senderNames ...map[string]string) map[string]interface{} {
var nameCache map[string]string
if len(senderNames) > 0 {
nameCache = senderNames[0]
}
return formatMessageItem(m, runtime, nameCache, nil, nil, false)
}
// FormatMessageItemWithMergePrefetch is like FormatMessageItem but threads a
// pre-fetched merge_forward sub-message map (typically built via
// PrefetchMergeForwardSubItems) through to the merge_forward converter so it
// can skip its own per-message GET. Shortcuts that iterate a page of raw
// items should pre-fetch once and call this variant in the loop to avoid the
// N × ~1s serial-merge_forward stall in the original code path.
//
// Kept as a nil-folder-prefetch compatibility wrapper for external callers;
// internal list commands have migrated to FormatMessageItemWithFolderPrefetchOpts.
func FormatMessageItemWithMergePrefetch(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
return formatMessageItem(m, runtime, nameCache, mergePrefetch, nil, false)
}
// FormatMessageItemWithMergePrefetchOpts is FormatMessageItemWithMergePrefetch
// with an explicit extractResources gate. When extractResources is true and
// the message carries downloadable resources, a "resources" block (ref list
// without local_path/size_bytes) is attached for the download enrichment stage
// to fill. The other entry points are thin extractResources=false wrappers, so
// default output is unchanged.
//
// Kept as a nil-folder-prefetch compatibility wrapper for external callers;
// internal list commands use FormatMessageItemWithFolderPrefetchOpts when a
// page may contain folder messages.
func FormatMessageItemWithMergePrefetchOpts(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) map[string]interface{} {
return formatMessageItem(m, runtime, nameCache, mergePrefetch, nil, extractResources)
}
// FormatMessageItemWithFolderPrefetchOpts is FormatMessageItemWithMergePrefetchOpts
// with an additional folder-children prefetch cache (built via
// PrefetchFolderChildren) keyed by folder-message message_id. Callers that
// render a page that may contain folder messages pass both prefetch maps so
// the folder converter reuses cached XML instead of N serial GETs.
func FormatMessageItemWithFolderPrefetchOpts(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, folderPrefetch map[string]string, extractResources bool) map[string]interface{} {
return formatMessageItem(m, runtime, nameCache, mergePrefetch, folderPrefetch, extractResources)
}
func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, folderPrefetch map[string]string, extractResources bool) map[string]interface{} {
msgType, _ := m["msg_type"].(string)
messageId, _ := m["message_id"].(string)
mentions, _ := m["mentions"].([]interface{})
deleted, _ := m["deleted"].(bool)
updated, _ := m["updated"].(bool)
content := ""
rawContent := ""
if body, ok := m["body"].(map[string]interface{}); ok {
rawContent, _ = body["content"].(string)
content = ConvertBodyContent(msgType, &ConvertContext{
RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions),
Mentions: mentions,
MessageID: messageId,
Runtime: runtime,
SenderNames: nameCache,
MergeForwardSubItems: mergePrefetch,
FolderChildren: folderPrefetch,
})
}
msg := map[string]interface{}{
"message_id": messageId,
"msg_type": msgType,
"content": content,
"sender": m["sender"],
"create_time": common.FormatTime(m["create_time"]),
"deleted": deleted,
"updated": updated,
}
// thread_id takes priority; fall back to reply_to (parent_id) if no thread
if tid, _ := m["thread_id"].(string); tid != "" {
msg["thread_id"] = tid
} else if pid, _ := m["parent_id"].(string); pid != "" {
msg["reply_to"] = pid
}
// Preserve API-provided fields (even if this formatter doesn't otherwise use them).
// update_time is only meaningful when the message was actually edited;
// the server echoes update_time == create_time for unedited messages, which
// would otherwise make every output look "updated" to downstream consumers.
if updated {
if v, ok := m["update_time"]; ok && v != nil {
if s, isStr := v.(string); isStr {
if strings.TrimSpace(s) != "" {
msg["update_time"] = common.FormatTime(s)
}
} else {
msg["update_time"] = common.FormatTime(v)
}
}
}
if v, ok := m["chat_id"]; ok {
msg["chat_id"] = v
}
if v, ok := m["message_position"]; ok {
msg["message_position"] = v
}
if v, ok := m["thread_message_position"]; ok {
msg["thread_message_position"] = v
}
// Prefer API-provided message_app_link when it's a non-empty string; otherwise assemble deterministically.
appLink, _ := m["message_app_link"].(string)
appLink = strings.TrimSpace(appLink)
if appLink == "" && runtime != nil && runtime.Config != nil {
appLink = assembleMessageAppLink(m, runtime.Config.Brand)
}
if appLink != "" {
msg["message_app_link"] = appLink
}
if len(mentions) > 0 {
simplified := make([]map[string]interface{}, 0, len(mentions))
for _, raw := range mentions {
item, _ := raw.(map[string]interface{})
key, _ := item["key"].(string)
name, _ := item["name"].(string)
simplified = append(simplified, map[string]interface{}{
"key": key,
"id": extractMentionOpenId(item["id"]),
"name": name,
})
}
msg["mentions"] = simplified
}
if extractResources {
if refs := ExtractResourceRefs(msgType, rawContent, messageId, mergePrefetch); len(refs) > 0 {
resources := make([]map[string]interface{}, 0, len(refs))
for _, r := range refs {
resources = append(resources, map[string]interface{}{
"message_id": r.MessageID,
"key": r.Key,
"type": r.Type,
})
}
msg["resources"] = resources
}
}
return msg
}
func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) string {
domain := resolveAppLinkDomain(brand)
if domain == "" {
return ""
}
chatID, _ := m["chat_id"].(string)
threadID, _ := m["thread_id"].(string)
msgPos, okMsgPos := normalizeMessagePosition(m["message_position"])
threadPos, okThreadPos := normalizeMessagePosition(m["thread_message_position"])
// Thread app link requires both thread_id and chat_id.
// Emit both underscore-less (openthreadid/openchatid) and snake_case (open_thread_id/open_chat_id)
// query keys so PC and mobile clients can both resolve the link.
if threadID != "" && chatID != "" && okThreadPos {
u := &url.URL{Scheme: "https", Host: domain, Path: "/client/thread/open"}
q := url.Values{}
q.Set("openthreadid", threadID)
q.Set("openchatid", chatID)
q.Set("open_thread_id", threadID)
q.Set("open_chat_id", chatID)
q.Set("thread_position", threadPos)
u.RawQuery = q.Encode()
return u.String()
}
if chatID != "" && okMsgPos {
u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"}
q := url.Values{}
q.Set("openChatId", chatID)
q.Set("position", msgPos)
u.RawQuery = q.Encode()
return u.String()
}
return ""
}
func normalizeMessagePosition(v interface{}) (string, bool) {
if v == nil {
return "", false
}
switch vv := v.(type) {
case float32:
f := float64(vv)
if math.IsNaN(f) || math.IsInf(f, 0) {
return "", false
}
if math.Trunc(f) == f {
return strconv.FormatInt(int64(f), 10), true
}
return strconv.FormatFloat(f, 'f', -1, 64), true
case float64:
if math.IsNaN(vv) || math.IsInf(vv, 0) {
return "", false
}
if math.Trunc(vv) == vv {
return strconv.FormatInt(int64(vv), 10), true
}
return strconv.FormatFloat(vv, 'f', -1, 64), true
case int:
return strconv.Itoa(vv), true
case int8:
return strconv.FormatInt(int64(vv), 10), true
case int16:
return strconv.FormatInt(int64(vv), 10), true
case int32:
return strconv.FormatInt(int64(vv), 10), true
case int64:
return strconv.FormatInt(vv, 10), true
case uint:
return strconv.FormatUint(uint64(vv), 10), true
case uint8:
return strconv.FormatUint(uint64(vv), 10), true
case uint16:
return strconv.FormatUint(uint64(vv), 10), true
case uint32:
return strconv.FormatUint(uint64(vv), 10), true
case uint64:
return strconv.FormatUint(vv, 10), true
case uintptr:
return strconv.FormatUint(uint64(vv), 10), true
case json.Number:
s := strings.TrimSpace(vv.String())
if s == "" {
return "", false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return "", false
}
if math.Trunc(f) == f {
return strconv.FormatInt(int64(f), 10), true
}
return strconv.FormatFloat(f, 'f', -1, 64), true
case string:
s := strings.TrimSpace(vv)
if s == "" {
return "", false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return "", false
}
if math.Trunc(f) == f {
return strconv.FormatInt(int64(f), 10), true
}
return strconv.FormatFloat(f, 'f', -1, 64), true
default:
// Fallback for typed numeric values (e.g. int32/uint64 via struct -> interface{}), pointers, etc.
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Ptr {
if rv.IsNil() {
return "", false
}
rv = rv.Elem()
}
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(rv.Int(), 10), true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return strconv.FormatUint(rv.Uint(), 10), true
case reflect.Float32, reflect.Float64:
f := rv.Float()
if math.IsNaN(f) || math.IsInf(f, 0) {
return "", false
}
if math.Trunc(f) == f {
return strconv.FormatInt(int64(f), 10), true
}
return strconv.FormatFloat(f, 'f', -1, 64), true
default:
return "", false
}
}
}
func resolveAppLinkDomain(brand core.LarkBrand) string {
appLink := core.ResolveEndpoints(brand).AppLink
u, err := url.Parse(appLink)
if err != nil {
return ""
}
return u.Host
}
// extractMentionOpenId extracts open_id from mention id (string or {"open_id":...} object).
func extractMentionOpenId(id interface{}) string {
if s, ok := id.(string); ok {
return s
}
if m, ok := id.(map[string]interface{}); ok {
if openId, ok := m["open_id"].(string); ok {
return openId
}
}
return ""
}
// TruncateContent truncates a string for table display.
func TruncateContent(s string, max int) string {
s = strings.ReplaceAll(s, "\n", " ")
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max]) + "…"
}