Files
larksuite__cli/shortcuts/im/convert_lib/content_convert.go
sammi-bytedance 501bf539af feat(im): complete audio/post rendering and add opt-in --download-resources (#1245)
Block 1 — field completion: audio renders <audio key="..." duration="Xs"/>
(falls back to [Voice: Xs]/[Voice]); post renders emotion -> :emoji_type:,
applies text.style (bold/italic/underline/lineThrough), passes through md;
sticker unchanged.

Block 2 — opt-in --download-resources (default off) on +chat-messages-list,
+messages-mget, +threads-messages-list: extract downloadable resource refs
during formatting (image/file/audio/video/media + post-embedded; sticker
excluded; merge_forward sub-items carry the top-level container message_id,
since the resources endpoint rejects sub-item ids with "234003 File not in
msg" and can only fetch a forwarded resource through the container; thread
replies get their own block), then download each distinct (message_id,
file_key) once into ./lark-im-resources/ with bounded concurrency (3), filling
back local_path/size_bytes; single-resource failures are isolated (error:true +
stderr warning). Path safety reuses normalizeDownloadOutputPath +
ResolveSavePath.

Batch download keys each file on disk by its unique file_key basename and only
appends an extension (from the Content-Disposition filename or MIME type) —
it does NOT substitute the server's Content-Disposition filename. Otherwise two
resources whose servers return the same filename (e.g. download.bin) would
resolve to the same ./lark-im-resources/ path and clobber each other
concurrently. The friendly "adopt the server filename" behavior is kept only
for an explicit +messages-resources-download with no --output.

Resource ref extraction guards against self-referential / cyclic merge_forward
prefetch maps (a real API sub-item list can include the container's own id or a
back-pointing merge_forward) via a visited set, so extraction terminates instead
of overflowing the stack. The container message_id is threaded through nested
merge_forwards as the download owner.

Also: document the feature (including the im:message:readonly scope requirement)
in skills/lark-im — SKILL.md is generated from skill-template/domains/im.md
(edit the source), plus the hand-written message-enrichment + 3 command
references.

Change-Id: I3a71d7d1b193130f551aaa2ec180ac1500d59ac4
Meego: https://meego.larkoffice.com/5e96d7bff4e7c525510f9156/story/detail/7331555925
2026-06-10 20:07:49 +08:00

429 lines
14 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{}
}
// 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, 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.
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, 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.
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, extractResources)
}
func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, 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,
})
}
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]) + "…"
}