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
This commit is contained in:
sammi-bytedance
2026-06-10 20:07:49 +08:00
committed by GitHub
parent 8e667db534
commit 501bf539af
26 changed files with 1238 additions and 61 deletions

View File

@@ -131,7 +131,7 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
if len(senderNames) > 0 {
nameCache = senderNames[0]
}
return formatMessageItem(m, runtime, nameCache, nil)
return formatMessageItem(m, runtime, nameCache, nil, false)
}
// FormatMessageItemWithMergePrefetch is like FormatMessageItem but threads a
@@ -141,10 +141,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
// 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)
return formatMessageItem(m, runtime, nameCache, mergePrefetch, false)
}
func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
// 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{})
@@ -152,8 +162,9 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
updated, _ := m["updated"].(bool)
content := ""
rawContent := ""
if body, ok := m["body"].(map[string]interface{}); ok {
rawContent, _ := body["content"].(string)
rawContent, _ = body["content"].(string)
content = ConvertBodyContent(msgType, &ConvertContext{
RawContent: rawContent,
MentionMap: BuildMentionKeyMap(mentions),
@@ -232,6 +243,20 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
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
}

View File

@@ -517,6 +517,79 @@ func TestMiscConverters(t *testing.T) {
}
}
// TestFormatMessageItemResourcesGate verifies the resources block is only
// emitted when extractResources is on; the default path (and back-compat
// wrappers) must never add a resources key.
func TestFormatMessageItemResourcesGate(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "image",
"message_id": "om_img",
"create_time": "1710500000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"image_key":"img_99"}`},
}
// Gate off via the back-compat wrapper.
off := FormatMessageItemWithMergePrefetch(raw, nil, nil, nil)
if _, ok := off["resources"]; ok {
t.Fatalf("FormatMessageItemWithMergePrefetch should not emit resources, got %#v", off["resources"])
}
// Gate off via plain FormatMessageItem.
plain := FormatMessageItem(raw, nil)
if _, ok := plain["resources"]; ok {
t.Fatalf("FormatMessageItem should not emit resources, got %#v", plain["resources"])
}
// Gate on.
on := FormatMessageItemWithMergePrefetchOpts(raw, nil, nil, nil, true)
resources, ok := on["resources"].([]map[string]interface{})
if !ok || len(resources) != 1 {
t.Fatalf("FormatMessageItemWithMergePrefetchOpts(extract=true) resources = %#v, want 1 ref", on["resources"])
}
r := resources[0]
if r["message_id"] != "om_img" || r["key"] != "img_99" || r["type"] != "image" {
t.Fatalf("resource ref = %#v, want {om_img,img_99,image}", r)
}
if _, ok := r["local_path"]; ok {
t.Fatalf("extract stage must not set local_path yet, got %#v", r["local_path"])
}
}
func TestAudioConverterFileKey(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "key and duration", raw: `{"file_key":"audio_1","duration":3500}`, want: `<audio key="audio_1" duration="4s"/>`},
{name: "key escaped", raw: `{"file_key":"a\"k","duration":2000}`, want: `<audio key="a\"k" duration="2s"/>`},
{name: "key without duration", raw: `{"file_key":"audio_2"}`, want: `<audio key="audio_2"/>`},
{name: "duration without key", raw: `{"duration":3500}`, want: "[Voice: 4s]"},
{name: "neither key nor duration", raw: `{}`, want: "[Voice]"},
{name: "invalid json", raw: `{invalid`, want: "[Invalid audio JSON]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := (audioMsgConverter{}).Convert(&ConvertContext{RawContent: tt.raw}); got != tt.want {
t.Fatalf("audioMsgConverter.Convert(%s) = %q, want %q", tt.name, got, tt.want)
}
})
}
}
// TestStickerUnchanged: DEC-001 default A keeps sticker rendering as [Sticker]
// regardless of payload; sticker must never be enriched or downloaded.
func TestStickerUnchanged(t *testing.T) {
if got := (stickerConverter{}).Convert(nil); got != "[Sticker]" {
t.Fatalf("stickerConverter.Convert(nil) = %q, want %q", got, "[Sticker]")
}
if got := (stickerConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"sticker_1"}`}); got != "[Sticker]" {
t.Fatalf("stickerConverter.Convert(with key) = %q, want %q", got, "[Sticker]")
}
}
func TestTodoConverter(t *testing.T) {
got := (todoConverter{}).Convert(&ConvertContext{RawContent: `{"task_id":"task_1","summary":{"title":"Finish report","content":[[{"tag":"text","text":"prepare slides"}]]},"due_time":"1710500000"}`})
want := "<todo task_id=\"task_1\">\nFinish report\nprepare slides\nDue: " + formatTimestamp("1710500000") + "\n</todo>"

View File

@@ -38,11 +38,21 @@ func (fileConverter) Convert(ctx *ConvertContext) string {
type audioMsgConverter struct{}
// Convert renders an audio message: when body.content carries a file_key it
// emits <audio key="..." duration="Xs"/> (duration omitted when absent);
// otherwise it falls back to [Voice: Xs] (duration only) or [Voice].
func (audioMsgConverter) Convert(ctx *ConvertContext) string {
parsed, err := ParseJSONObject(ctx.RawContent)
if err != nil {
return invalidJSONPlaceholder("audio")
}
if key, _ := parsed["file_key"].(string); key != "" {
result := fmt.Sprintf(`<audio key="%s"`, cardEscapeAttr(key))
if dur, ok := parsed["duration"].(float64); ok && dur > 0 {
result += fmt.Sprintf(` duration="%.0fs"`, dur/1000)
}
return result + "/>"
}
if dur, ok := parsed["duration"].(float64); ok && dur > 0 {
return fmt.Sprintf("[Voice: %.0fs]", dur/1000)
}

View File

@@ -162,7 +162,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
map[string]interface{}{"queries": queries},
)
if err != nil {
warnReactionsf(stderrMu, runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
warnSyncf(stderrMu, runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
markReactionsError(batchIDs, idIndex)
return
}
@@ -204,7 +204,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
}
}
if len(failedIDs) > 0 {
warnReactionsf(stderrMu, runtime.IO().ErrOut,
warnSyncf(stderrMu, runtime.IO().ErrOut,
"warning: reactions_partial_failed: %d message(s) failed (%v)\n",
len(failedIDs), failedIDs)
markReactionsError(failedIDs, idIndex)
@@ -212,11 +212,12 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
}
}
// warnReactionsf writes a stderr warning under the supplied mutex when one is
// provided (multi-batch concurrent path), so concurrent goroutines can't
// interleave partial lines. mu == nil means the caller is on the single-batch
// fast path where no synchronization is needed.
func warnReactionsf(mu *sync.Mutex, w io.Writer, format string, args ...interface{}) {
// warnSyncf writes a stderr warning under the supplied mutex when one is
// provided (multi-batch / multi-download concurrent paths), so concurrent
// goroutines can't interleave partial lines. mu == nil means the caller is on a
// single-item fast path where no synchronization is needed. It is domain-neutral
// — shared by reactions batch query and resource download enrichment.
func warnSyncf(mu *sync.Mutex, w io.Writer, format string, args ...interface{}) {
if mu != nil {
mu.Lock()
defer mu.Unlock()

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"context"
"sync"
"github.com/larksuite/cli/shortcuts/common"
)
// resourceDownloadConcurrency caps in-flight resource downloads. Each download
// is a GET plus a local disk write; capping at 3 keeps the
// messages/{id}/resources/{key} endpoint well under any gateway-layer rate
// ceiling while still cutting wall-clock versus a serial loop.
const resourceDownloadConcurrency = 3
// ResourceDownloader downloads one resource and returns its local path and
// size in bytes. messageID is the resource's owning message id (the download
// API path parameter), key is the file_key/image_key, and fileType is the
// download API resource type ("image" or "file"). A non-nil error means the
// single resource failed; the engine isolates that failure (fail-silent).
type ResourceDownloader func(ctx context.Context, messageID, key, fileType string) (string, int64, error)
// EnrichResourceDownloads walks every message node (including nested
// thread_replies) for "resources" blocks attached during formatting, downloads
// each distinct (message_id, key) once with bounded concurrency, and fills
// local_path/size_bytes back into every ref sharing that key. A single
// resource failing is isolated: its ref is flagged "error": true and a warning
// is written to stderr, while the main message and the other resources are
// unaffected (S2.STA-DES-P0-002 weak-dependency isolation).
func EnrichResourceDownloads(runtime *common.RuntimeContext, messages []map[string]interface{}, dl ResourceDownloader) {
if len(messages) == 0 || dl == nil {
return
}
type refKey struct {
messageID string
key string
}
groups := make(map[refKey][]map[string]interface{})
types := make(map[refKey]string)
var order []refKey
collectResourceRefs(messages, func(ref map[string]interface{}) {
messageID, _ := ref["message_id"].(string)
key, _ := ref["key"].(string)
if messageID == "" || key == "" {
return
}
rk := refKey{messageID: messageID, key: key}
if _, seen := groups[rk]; !seen {
order = append(order, rk)
if t, _ := ref["type"].(string); t != "" {
types[rk] = t
}
}
groups[rk] = append(groups[rk], ref)
})
if len(order) == 0 {
return
}
ctx := runtime.Ctx()
var stderrMu sync.Mutex
download := func(rk refKey) {
if err := ctx.Err(); err != nil {
return
}
localPath, size, err := dl(ctx, rk.messageID, rk.key, types[rk])
if err != nil {
warnSyncf(&stderrMu, runtime.IO().ErrOut,
"warning: resource_download_failed: %s/%s: %v\n", rk.messageID, rk.key, err)
for _, ref := range groups[rk] {
ref["error"] = true
}
return
}
for _, ref := range groups[rk] {
ref["local_path"] = localPath
ref["size_bytes"] = size
}
}
// Single-resource fast path: no goroutine overhead, deterministic stderr.
if len(order) == 1 {
download(order[0])
return
}
// Bounded-concurrency fan-out. Each goroutine writes only to its own
// (message_id, key) group's ref maps — distinct keys map to distinct ref
// maps, so there is no shared mutable state besides the stderr mutex.
sem := make(chan struct{}, resourceDownloadConcurrency)
var wg sync.WaitGroup
for _, rk := range order {
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
download(rk)
}()
}
wg.Wait()
}
// collectResourceRefs walks messages (and nested thread_replies) and invokes fn
// for every resource ref map found in each node's "resources" block. Handles
// both the typed []map[string]interface{} (in-memory, set by formatMessageItem)
// and []interface{} (post JSON round-trip) shapes, mirroring collectMessageNodes.
func collectResourceRefs(messages []map[string]interface{}, fn func(ref map[string]interface{})) {
for _, msg := range messages {
switch res := msg["resources"].(type) {
case []map[string]interface{}:
for _, ref := range res {
fn(ref)
}
case []interface{}:
for _, raw := range res {
if ref, ok := raw.(map[string]interface{}); ok {
fn(ref)
}
}
}
switch nested := msg["thread_replies"].(type) {
case []map[string]interface{}:
collectResourceRefs(nested, fn)
case []interface{}:
typed := make([]map[string]interface{}, 0, len(nested))
for _, raw := range nested {
if m, ok := raw.(map[string]interface{}); ok {
typed = append(typed, m)
}
}
collectResourceRefs(typed, fn)
}
}
}

View File

@@ -0,0 +1,219 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
)
// unusedRoundTrip is a transport that fails if the engine ever issues HTTP —
// EnrichResourceDownloads must drive all IO through the injected downloader.
func unusedRoundTrip(t *testing.T) http.RoundTripper {
t.Helper()
return convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("EnrichResourceDownloads must not issue HTTP directly, got %s", req.URL.String())
return nil, nil
})
}
func resourceRef(messageID, key, fileType string) map[string]interface{} {
return map[string]interface{}{"message_id": messageID, "key": key, "type": fileType}
}
func TestEnrichResourceDownloads_Dedup(t *testing.T) {
runtime := newBotConvertlibRuntime(t, unusedRoundTrip(t))
var mu sync.Mutex
calls := map[string]int{}
dl := func(_ context.Context, messageID, key, fileType string) (string, int64, error) {
mu.Lock()
calls[messageID+"/"+key]++
mu.Unlock()
return "lark-im-resources/" + key, 10, nil
}
// Same (message_id, key) appears on two distinct message maps (e.g. mget
// with a duplicated id). The downloader must run once, both refs fill back.
messages := []map[string]interface{}{
{"message_id": "om_1", "resources": []map[string]interface{}{resourceRef("om_1", "k1", "file")}},
{"message_id": "om_1", "resources": []map[string]interface{}{resourceRef("om_1", "k1", "file")}},
}
EnrichResourceDownloads(runtime, messages, dl)
if calls["om_1/k1"] != 1 {
t.Fatalf("downloader called %d times for om_1/k1, want 1 (dedup)", calls["om_1/k1"])
}
for i, m := range messages {
refs := m["resources"].([]map[string]interface{})
if refs[0]["local_path"] != "lark-im-resources/k1" {
t.Fatalf("message %d ref not filled back: %#v", i, refs[0])
}
}
}
// TestEnrichResourceDownloads_BoundedConcurrency deterministically proves the
// semaphore admits exactly resourceDownloadConcurrency downloads at once and
// blocks the next one — without relying on sleep-based peak sampling. Each
// download signals on `entered` then blocks on `release`; we assert that
// exactly `resourceDownloadConcurrency` enter and that one more stays blocked
// until we release.
func TestEnrichResourceDownloads_BoundedConcurrency(t *testing.T) {
runtime := newBotConvertlibRuntime(t, unusedRoundTrip(t))
total := resourceDownloadConcurrency + 3
entered := make(chan struct{}, total)
release := make(chan struct{})
dl := func(_ context.Context, messageID, key, fileType string) (string, int64, error) {
entered <- struct{}{}
<-release
return "p/" + key, 1, nil
}
messages := make([]map[string]interface{}, total)
for i := range messages {
id := fmt.Sprintf("om_%02d", i)
messages[i] = map[string]interface{}{
"message_id": id,
"resources": []map[string]interface{}{resourceRef(id, fmt.Sprintf("k%02d", i), "file")},
}
}
done := make(chan struct{})
go func() {
EnrichResourceDownloads(runtime, messages, dl)
close(done)
}()
// Exactly resourceDownloadConcurrency downloads must start concurrently.
for i := 0; i < resourceDownloadConcurrency; i++ {
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatalf("only %d downloads started, want %d concurrent", i, resourceDownloadConcurrency)
}
}
// One more must NOT start while the first batch is still in flight — the
// semaphore caps it, so the peak can never exceed resourceDownloadConcurrency.
select {
case <-entered:
t.Fatalf("a download beyond the cap (%d) started while the batch was in flight", resourceDownloadConcurrency)
case <-time.After(200 * time.Millisecond):
// expected: blocked on the semaphore
}
close(release)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatalf("EnrichResourceDownloads did not finish after release")
}
}
func TestEnrichResourceDownloads_FillBack(t *testing.T) {
runtime := newBotConvertlibRuntime(t, unusedRoundTrip(t))
dl := func(_ context.Context, messageID, key, fileType string) (string, int64, error) {
return "lark-im-resources/voice.mp3", 12345, nil
}
messages := []map[string]interface{}{
{"message_id": "om_1", "content": "[Voice]", "resources": []map[string]interface{}{resourceRef("om_1", "a_1", "file")}},
}
EnrichResourceDownloads(runtime, messages, dl)
ref := messages[0]["resources"].([]map[string]interface{})[0]
if ref["local_path"] != "lark-im-resources/voice.mp3" {
t.Fatalf("local_path = %#v, want lark-im-resources/voice.mp3", ref["local_path"])
}
if ref["size_bytes"] != int64(12345) {
t.Fatalf("size_bytes = %#v (type %T), want int64(12345)", ref["size_bytes"], ref["size_bytes"])
}
if _, ok := ref["error"]; ok {
t.Fatalf("successful download must not set error: %#v", ref)
}
}
func TestEnrichResourceDownloads_FailSilent(t *testing.T) {
runtime := newBotConvertlibRuntime(t, unusedRoundTrip(t))
dl := func(_ context.Context, messageID, key, fileType string) (string, int64, error) {
if key == "bad" {
return "", 0, fmt.Errorf("scope insufficient")
}
return "lark-im-resources/" + key, 7, nil
}
messages := []map[string]interface{}{
{"message_id": "om_1", "content": "[File]", "resources": []map[string]interface{}{
resourceRef("om_1", "bad", "file"),
resourceRef("om_1", "good", "file"),
}},
}
EnrichResourceDownloads(runtime, messages, dl)
refs := messages[0]["resources"].([]map[string]interface{})
if refs[0]["error"] != true {
t.Fatalf("failed resource must be flagged error:true, got %#v", refs[0])
}
if _, ok := refs[0]["local_path"]; ok {
t.Fatalf("failed resource must not have local_path: %#v", refs[0])
}
if refs[1]["local_path"] != "lark-im-resources/good" {
t.Fatalf("other resource must still download: %#v", refs[1])
}
if messages[0]["content"] != "[File]" {
t.Fatalf("main message content must be untouched, got %#v", messages[0]["content"])
}
errOut := runtime.IO().ErrOut.(*bytes.Buffer).String()
if !strings.Contains(errOut, "warning") {
t.Fatalf("expected stderr warning for failed download, got %q", errOut)
}
}
func TestEnrichResourceDownloads_WalksThreadReplies(t *testing.T) {
runtime := newBotConvertlibRuntime(t, unusedRoundTrip(t))
var mu sync.Mutex
seen := map[string]bool{}
dl := func(_ context.Context, messageID, key, fileType string) (string, int64, error) {
mu.Lock()
seen[messageID+"/"+key] = true
mu.Unlock()
return "p/" + key, 1, nil
}
messages := []map[string]interface{}{
{
"message_id": "om_root",
"resources": []map[string]interface{}{resourceRef("om_root", "root_key", "image")},
"thread_replies": []map[string]interface{}{
{"message_id": "om_reply", "resources": []map[string]interface{}{resourceRef("om_reply", "reply_key", "file")}},
},
},
}
EnrichResourceDownloads(runtime, messages, dl)
if !seen["om_root/root_key"] {
t.Fatalf("root resource not downloaded: %#v", seen)
}
if !seen["om_reply/reply_key"] {
t.Fatalf("thread_reply resource not downloaded (walk missed nested node): %#v", seen)
}
reply := messages[0]["thread_replies"].([]map[string]interface{})[0]
ref := reply["resources"].([]map[string]interface{})[0]
if ref["local_path"] != "p/reply_key" {
t.Fatalf("thread_reply ref not filled back: %#v", ref)
}
}

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
// ResourceRef is a downloadable resource reference extracted from a message
// during formatting. Type is the download API resource type ("image" or
// "file"); MessageID is the message id used as the download API path parameter.
// For a standalone message that is the message's own id; for a resource carried
// inside a merge_forward it is the TOP-LEVEL container's id, because the
// download API addresses forwarded resources by the container, not the sub-item
// (see extractMergeForwardResourceRefs). The extract stage fills these three
// fields only — local_path and size_bytes are filled later by the download
// enrichment stage.
type ResourceRef struct {
MessageID string
Key string
Type string
}
// ExtractResourceRefs returns the downloadable resource refs carried by a
// single message's raw content. It is a pure function (no IO/runtime).
//
// Type mapping (design GAP-002):
// - image, post img (image_key) -> type "image"
// - file, audio, video, media, post media -> type "file"
// - sticker -> never extracted (unsupported)
//
// For merge_forward the sub-items are not standalone message nodes, so this
// folds them in from mergeSub (the pre-fetched flat sub-item list keyed by the
// merge_forward message_id); each sub-item ref carries the TOP-LEVEL container's
// message_id, since the download API rejects sub-item ids (234003 File not in
// msg) and can only fetch a forwarded resource through the container.
// Refs without a usable key are skipped.
func ExtractResourceRefs(msgType, rawContent, messageID string, mergeSub map[string][]map[string]interface{}) []ResourceRef {
return extractResourceRefs(msgType, rawContent, messageID, mergeSub, nil)
}
// extractResourceRefs is ExtractResourceRefs with a visited set threaded through
// merge_forward recursion. The set guards against self-referential or cyclic
// prefetch maps — a real merge_forward's flat sub-item list can include the
// container itself (or a nested merge_forward that points back), which would
// otherwise recurse until the stack overflows.
func extractResourceRefs(msgType, rawContent, messageID string, mergeSub map[string][]map[string]interface{}, visited map[string]bool) []ResourceRef {
switch msgType {
case "image":
if key := jsonStringField(rawContent, "image_key"); key != "" {
return []ResourceRef{{MessageID: messageID, Key: key, Type: "image"}}
}
case "file", "audio", "video", "media":
if key := jsonStringField(rawContent, "file_key"); key != "" {
return []ResourceRef{{MessageID: messageID, Key: key, Type: "file"}}
}
case "post":
return extractPostResourceRefs(rawContent, messageID)
case "merge_forward":
return extractMergeForwardResourceRefs(messageID, mergeSub, visited)
}
return nil
}
// extractPostResourceRefs walks a post body's elements and collects img/media
// resource refs.
func extractPostResourceRefs(rawContent, messageID string) []ResourceRef {
parsed, err := ParseJSONObject(rawContent)
if err != nil || parsed == nil {
return nil
}
body := unwrapPostLocale(parsed)
if body == nil {
return nil
}
blocks, _ := body["content"].([]interface{})
var refs []ResourceRef
for _, para := range blocks {
elems, _ := para.([]interface{})
for _, el := range elems {
elem, _ := el.(map[string]interface{})
if elem == nil {
continue
}
switch tag, _ := elem["tag"].(string); tag {
case "img":
if key, _ := elem["image_key"].(string); key != "" {
refs = append(refs, ResourceRef{MessageID: messageID, Key: key, Type: "image"})
}
case "media":
if key, _ := elem["file_key"].(string); key != "" {
refs = append(refs, ResourceRef{MessageID: messageID, Key: key, Type: "file"})
}
}
}
}
return refs
}
// extractMergeForwardResourceRefs folds resources from a merge_forward's
// pre-fetched sub-items. Every collected ref carries the TOP-LEVEL container's
// message_id (messageID here), NOT the sub-item's own id: the resources endpoint
// (GET /open-apis/im/v1/messages/:message_id/resources/:file_key) rejects a
// sub-item id with "234003 File not in msg" and can only fetch a forwarded
// resource through the container that was actually retrieved from the chat.
//
// A sub-item that is itself a merge_forward recurses through the same prefetch
// map (absent keys yield nothing, fail-silent) while keeping the same top-level
// owner — nested merge_forward ids are virtual sub-items too, so they cannot
// own a download either. The visited set breaks cycles: a real API sub-item
// list can contain the container's own id or a back-pointing merge_forward, so
// we expand each merge_forward id at most once.
func extractMergeForwardResourceRefs(messageID string, mergeSub map[string][]map[string]interface{}, visited map[string]bool) []ResourceRef {
return collectMergeForwardResourceRefs(messageID, messageID, mergeSub, visited)
}
// collectMergeForwardResourceRefs expands the merge_forward identified by
// lookupID and returns its leaf resource refs, each addressed for download by
// ownerID — the top-level merge_forward container the caller fetched. ownerID
// stays fixed across nested merge_forwards while lookupID descends into them.
func collectMergeForwardResourceRefs(ownerID, lookupID string, mergeSub map[string][]map[string]interface{}, visited map[string]bool) []ResourceRef {
if visited == nil {
visited = make(map[string]bool)
}
if visited[lookupID] {
return nil
}
visited[lookupID] = true
subItems := mergeSub[lookupID]
if len(subItems) == 0 {
return nil
}
var refs []ResourceRef
for _, sub := range subItems {
subType, _ := sub["msg_type"].(string)
subID, _ := sub["message_id"].(string)
subRaw := ""
if body, ok := sub["body"].(map[string]interface{}); ok {
subRaw, _ = body["content"].(string)
}
if subType == "merge_forward" {
// Nested merge_forward: descend by its own id, but keep downloading
// every leaf through the same top-level container (ownerID).
refs = append(refs, collectMergeForwardResourceRefs(ownerID, subID, mergeSub, visited)...)
continue
}
// Leaf sub-item: its resources download through the container (ownerID),
// not subID.
refs = append(refs, extractResourceRefs(subType, subRaw, ownerID, mergeSub, visited)...)
}
return refs
}
// jsonStringField parses raw as a JSON object and returns the named string
// field, or "" if parsing fails or the field is missing/non-string.
func jsonStringField(raw, field string) string {
parsed, err := ParseJSONObject(raw)
if err != nil {
return ""
}
s, _ := parsed[field].(string)
return s
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"reflect"
"testing"
)
func TestExtractResourceRefs(t *testing.T) {
tests := []struct {
name string
msgType string
raw string
messageID string
want []ResourceRef
}{
{name: "image", msgType: "image", raw: `{"image_key":"img_1"}`, messageID: "om_1", want: []ResourceRef{{MessageID: "om_1", Key: "img_1", Type: "image"}}},
{name: "file", msgType: "file", raw: `{"file_key":"f_1"}`, messageID: "om_2", want: []ResourceRef{{MessageID: "om_2", Key: "f_1", Type: "file"}}},
{name: "audio", msgType: "audio", raw: `{"file_key":"a_1","duration":1000}`, messageID: "om_3", want: []ResourceRef{{MessageID: "om_3", Key: "a_1", Type: "file"}}},
{name: "video", msgType: "video", raw: `{"file_key":"v_1"}`, messageID: "om_4", want: []ResourceRef{{MessageID: "om_4", Key: "v_1", Type: "file"}}},
{name: "media", msgType: "media", raw: `{"file_key":"m_1"}`, messageID: "om_5", want: []ResourceRef{{MessageID: "om_5", Key: "m_1", Type: "file"}}},
{name: "sticker not extracted", msgType: "sticker", raw: `{"file_key":"s_1"}`, messageID: "om_6", want: nil},
{name: "image without key skipped", msgType: "image", raw: `{}`, messageID: "om_7", want: nil},
{name: "file without key skipped", msgType: "file", raw: `{}`, messageID: "om_8", want: nil},
{name: "invalid json skipped", msgType: "image", raw: `{invalid`, messageID: "om_9", want: nil},
{name: "text has no resource", msgType: "text", raw: `{"text":"hi"}`, messageID: "om_10", want: nil},
{
name: "post img and media",
msgType: "post",
raw: `{"zh_cn":{"content":[[{"tag":"img","image_key":"post_img"},{"tag":"text","text":"x"}],[{"tag":"media","file_key":"post_media"}]]}}`,
messageID: "om_11",
want: []ResourceRef{{MessageID: "om_11", Key: "post_img", Type: "image"}, {MessageID: "om_11", Key: "post_media", Type: "file"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractResourceRefs(tt.msgType, tt.raw, tt.messageID, nil)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("ExtractResourceRefs(%s) = %#v, want %#v", tt.name, got, tt.want)
}
})
}
}
// TestExtractMergeForwardSubItemRefs verifies merge_forward sub-item resources
// fold into the parent's ref list, each carrying the TOP-LEVEL container's
// message_id (NOT the sub-item's own id — the download API rejects sub-item ids
// with 234003 File not in msg), and that sticker sub-items are skipped.
func TestExtractMergeForwardSubItemRefs(t *testing.T) {
mergeSub := map[string][]map[string]interface{}{
"mf_1": {
{"message_id": "sub_img", "msg_type": "image", "body": map[string]interface{}{"content": `{"image_key":"img_s"}`}},
{"message_id": "sub_sticker", "msg_type": "sticker", "body": map[string]interface{}{"content": `{"file_key":"k"}`}},
{"message_id": "sub_file", "msg_type": "file", "body": map[string]interface{}{"content": `{"file_key":"f_s"}`}},
},
}
got := ExtractResourceRefs("merge_forward", "", "mf_1", mergeSub)
want := []ResourceRef{
{MessageID: "mf_1", Key: "img_s", Type: "image"},
{MessageID: "mf_1", Key: "f_s", Type: "file"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ExtractResourceRefs(merge_forward) = %#v, want %#v", got, want)
}
// No prefetch entry → no refs (sub-items unavailable, fail-silent).
if got := ExtractResourceRefs("merge_forward", "", "mf_absent", mergeSub); got != nil {
t.Fatalf("ExtractResourceRefs(merge_forward absent) = %#v, want nil", got)
}
}
// TestExtractMergeForwardSubItemRefs_Cyclic is a regression test for a real
// stack overflow: a merge_forward's prefetched flat sub-item list can include
// the container's own id and/or a back-pointing merge_forward, which previously
// recursed until the stack blew up. The visited guard must expand each id once
// and still collect the leaf resources.
func TestExtractMergeForwardSubItemRefs_Cyclic(t *testing.T) {
mergeSub := map[string][]map[string]interface{}{
// mf_root lists ITSELF (container appears in its own sub-list) plus a leaf
// and a nested merge_forward that points back to mf_root.
"mf_root": {
{"message_id": "mf_root", "msg_type": "merge_forward", "body": map[string]interface{}{"content": "[Merged forward]"}},
{"message_id": "sub_img", "msg_type": "image", "body": map[string]interface{}{"content": `{"image_key":"img_c"}`}},
{"message_id": "mf_child", "msg_type": "merge_forward", "body": map[string]interface{}{"content": "[Merged forward]"}},
},
"mf_child": {
{"message_id": "mf_root", "msg_type": "merge_forward", "body": map[string]interface{}{"content": "[Merged forward]"}},
{"message_id": "sub_file", "msg_type": "file", "body": map[string]interface{}{"content": `{"file_key":"file_c"}`}},
},
}
// Must terminate (no stack overflow) and collect both leaf resources once,
// each addressed by the top-level container id (mf_root) for download — even
// the resource nested inside mf_child, since nested merge_forward ids are
// virtual sub-items that cannot own a download either.
got := ExtractResourceRefs("merge_forward", "", "mf_root", mergeSub)
want := []ResourceRef{
{MessageID: "mf_root", Key: "img_c", Type: "image"},
{MessageID: "mf_root", Key: "file_c", Type: "file"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ExtractResourceRefs(cyclic merge_forward) = %#v, want %#v", got, want)
}
}

View File

@@ -86,32 +86,55 @@ func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} {
return nil
}
// renderPostElem renders a single post (rich-text) element to its inline text
// form: text/a/at carry their content through applyPostStyle for text.style
// Markdown emphasis, emotion becomes :emoji_type:, md is passed through raw,
// and unknown tags fall back to the element's text.
func renderPostElem(el map[string]interface{}) string {
tag, _ := el["tag"].(string)
switch tag {
case "text":
text, _ := el["text"].(string)
return text
return applyPostStyle(text, el["style"])
case "a":
text, _ := el["text"].(string)
href, _ := el["href"].(string)
if href != "" && text != "" {
return fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), href)
var rendered string
switch {
case href != "" && text != "":
rendered = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), href)
case href != "":
rendered = href
default:
rendered = text
}
if href != "" {
return href
}
return text
return applyPostStyle(rendered, el["style"])
case "at":
userId, _ := el["user_id"].(string)
if userId == "@_all" || userId == "all" {
return "@all"
var rendered string
switch {
case userId == "@_all" || userId == "all":
rendered = "@all"
default:
if name, _ := el["user_name"].(string); name != "" {
rendered = "@" + name
} else {
rendered = "@" + userId
}
}
name, _ := el["user_name"].(string)
if name != "" {
return "@" + name
return applyPostStyle(rendered, el["style"])
case "emotion":
// Deliberately not routed through applyPostStyle: an emoji shortcode is
// an atomic token, not prose, so bold/italic/strike emphasis around
// ":emoji:" would be meaningless (and emotion elements don't carry style).
emoji, _ := el["emoji_type"].(string)
if emoji == "" {
return ""
}
return "@" + userId
return ":" + emoji + ":"
case "md":
text, _ := el["text"].(string)
return text
case "img":
key, _ := el["image_key"].(string)
if key != "" {
@@ -138,3 +161,38 @@ func renderPostElem(el map[string]interface{}) string {
return text
}
}
// applyPostStyle wraps text with Markdown emphasis per the post element's
// style array (bold/italic/underline/lineThrough). Styles compose from inner
// to outer in a fixed order so output is deterministic; empty text or no
// styles pass through unchanged.
func applyPostStyle(text string, raw interface{}) string {
if text == "" {
return text
}
styles, _ := raw.([]interface{})
if len(styles) == 0 {
return text
}
has := func(name string) bool {
for _, s := range styles {
if v, _ := s.(string); v == name {
return true
}
}
return false
}
if has("bold") {
text = "**" + text + "**"
}
if has("italic") {
text = "*" + text + "*"
}
if has("underline") {
text = "<u>" + text + "</u>"
}
if has("lineThrough") {
text = "~~" + text + "~~"
}
return text
}

View File

@@ -110,3 +110,37 @@ func TestRenderPostElem(t *testing.T) {
})
}
}
// TestRenderPostElemEmotionStyleMd covers the 3 gaps closed in design §字段补全:
// emotion -> :emoji_type:, text.style -> Markdown emphasis (composable),
// md -> raw passthrough, while unknown tags keep the default text fallback.
func TestRenderPostElemEmotionStyleMd(t *testing.T) {
tests := []struct {
name string
el map[string]interface{}
want string
}{
{name: "emotion", el: map[string]interface{}{"tag": "emotion", "emoji_type": "SMILE"}, want: ":SMILE:"},
{name: "emotion empty", el: map[string]interface{}{"tag": "emotion"}, want: ""},
{name: "md passthrough", el: map[string]interface{}{"tag": "md", "text": "# Heading\n- item"}, want: "# Heading\n- item"},
{name: "style bold", el: map[string]interface{}{"tag": "text", "text": "hi", "style": []interface{}{"bold"}}, want: "**hi**"},
{name: "style italic", el: map[string]interface{}{"tag": "text", "text": "hi", "style": []interface{}{"italic"}}, want: "*hi*"},
{name: "style underline", el: map[string]interface{}{"tag": "text", "text": "hi", "style": []interface{}{"underline"}}, want: "<u>hi</u>"},
{name: "style lineThrough", el: map[string]interface{}{"tag": "text", "text": "hi", "style": []interface{}{"lineThrough"}}, want: "~~hi~~"},
{name: "style composable bold+lineThrough", el: map[string]interface{}{"tag": "text", "text": "hi", "style": []interface{}{"bold", "lineThrough"}}, want: "~~**hi**~~"},
// bold+italic collapses to ***hi*** (CommonMark-valid), not *(**hi**)*.
{name: "style composable bold+italic", el: map[string]interface{}{"tag": "text", "text": "hi", "style": []interface{}{"bold", "italic"}}, want: "***hi***"},
{name: "style empty no wrap", el: map[string]interface{}{"tag": "text", "text": "plain", "style": []interface{}{}}, want: "plain"},
{name: "link with style", el: map[string]interface{}{"tag": "a", "text": "doc", "href": "https://example.com", "style": []interface{}{"bold"}}, want: "**[doc](https://example.com)**"},
{name: "mention with style", el: map[string]interface{}{"tag": "at", "user_name": "Alice", "style": []interface{}{"italic"}}, want: "*@Alice*"},
{name: "unknown tag default", el: map[string]interface{}{"tag": "weird", "text": "fallback"}, want: "fallback"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := renderPostElem(tt.el); got != tt.want {
t.Fatalf("renderPostElem(%s) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -68,6 +68,15 @@ const threadRepliesFetchConcurrency = 8
// 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
}
@@ -205,7 +214,7 @@ func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]i
}
replies := make([]map[string]interface{}, 0, len(r.rawReplies))
for _, raw := range r.rawReplies {
replies = append(replies, FormatMessageItemWithMergePrefetch(raw, runtime, nameCache, mergePrefetch))
replies = append(replies, FormatMessageItemWithMergePrefetchOpts(raw, runtime, nameCache, mergePrefetch, extractResources))
}
preparedReplies[i] = replies
}

View File

@@ -68,6 +68,118 @@ func TestExpandThreadReplies(t *testing.T) {
}
}
// TestExpandThreadRepliesResources verifies that when extractResources is on,
// each thread reply gets its own resources block with ref message_id equal to
// the reply's own message_id.
func TestExpandThreadRepliesResources(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"message_id": "om_reply_img",
"msg_type": "image",
"create_time": "1710500000",
"thread_id": "omt_1",
"sender": map[string]interface{}{"name": "Alice"},
"body": map[string]interface{}{"content": `{"image_key":"img_reply"}`},
},
},
},
}), nil
}))
messages := []map[string]interface{}{
{"message_id": "om_root_1", "thread_id": "omt_1"},
}
ExpandThreadRepliesWithResources(runtime, messages, map[string]string{}, 10, 50, true)
replies, _ := messages[0]["thread_replies"].([]map[string]interface{})
if len(replies) != 1 {
t.Fatalf("thread_replies len = %d, want 1", len(replies))
}
resources, ok := replies[0]["resources"].([]map[string]interface{})
if !ok || len(resources) != 1 {
t.Fatalf("reply resources = %#v, want 1 ref", replies[0]["resources"])
}
r := resources[0]
if r["message_id"] != "om_reply_img" || r["key"] != "img_reply" || r["type"] != "image" {
t.Fatalf("reply resource ref = %#v, want {om_reply_img,img_reply,image}", r)
}
}
// TestThreadReplyMergeForwardNested verifies that when a thread reply is itself
// a merge_forward, its sub-item resources fold into that reply's resources
// block, each ref carrying the merge_forward CONTAINER's message_id (the
// download API rejects sub-item ids with 234003 File not in msg).
func TestThreadReplyMergeForwardNested(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
// merge_forward sub-message prefetch: GET /messages/{id} (no container_id query).
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_reply_mf"):
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"message_id": "sub_in_mf",
"msg_type": "file",
"create_time": "1710500000",
"sender": map[string]interface{}{"name": "Bob"},
"body": map[string]interface{}{"content": `{"file_key":"file_in_mf"}`},
},
},
},
}), nil
// thread replies fetch: GET /messages?container_id=...
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages"):
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"message_id": "om_reply_mf",
"msg_type": "merge_forward",
"create_time": "1710500000",
"thread_id": "omt_1",
"sender": map[string]interface{}{"name": "Alice"},
"body": map[string]interface{}{"content": "[Merged forward]"},
},
},
},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
messages := []map[string]interface{}{
{"message_id": "om_root_1", "thread_id": "omt_1"},
}
ExpandThreadRepliesWithResources(runtime, messages, map[string]string{}, 10, 50, true)
replies, _ := messages[0]["thread_replies"].([]map[string]interface{})
if len(replies) != 1 {
t.Fatalf("thread_replies len = %d, want 1", len(replies))
}
resources, ok := replies[0]["resources"].([]map[string]interface{})
if !ok || len(resources) != 1 {
t.Fatalf("nested merge_forward reply resources = %#v, want 1 ref", replies[0]["resources"])
}
r := resources[0]
if r["message_id"] != "om_reply_mf" || r["key"] != "file_in_mf" || r["type"] != "file" {
t.Fatalf("nested resource ref = %#v, want {om_reply_mf,file_in_mf,file}", r)
}
}
func TestFetchThreadRepliesError(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {

View File

@@ -553,16 +553,16 @@ func TestParseContentDispositionFilename(t *testing.T) {
func TestResolveIMResourceDownloadPath(t *testing.T) {
tests := []struct {
name string
safePath string
contentType string
contentDisposition string
userSpecifiedOutput bool
want string
name string
safePath string
contentType string
contentDisposition string
preserveBasename bool
want string
}{
// safePath already has extension: always return as-is
{name: "user path with ext, no CD", safePath: "out.xlsx", contentType: "application/pdf", userSpecifiedOutput: true, want: "out.xlsx"},
{name: "user path with ext, CD present", safePath: "out.xlsx", contentDisposition: `attachment; filename="server.pdf"`, userSpecifiedOutput: true, want: "out.xlsx"},
{name: "user path with ext, no CD", safePath: "out.xlsx", contentType: "application/pdf", preserveBasename: true, want: "out.xlsx"},
{name: "user path with ext, CD present", safePath: "out.xlsx", contentDisposition: `attachment; filename="server.pdf"`, preserveBasename: true, want: "out.xlsx"},
// No --output: use CD filename when present
{name: "default path, CD filename", safePath: "file_xxx", contentDisposition: `attachment; filename="季度报告.xlsx"`, want: "季度报告.xlsx"},
{name: "default path, CD RFC5987", safePath: "file_xxx", contentDisposition: `attachment; filename*=UTF-8''%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A.xlsx`, want: "季度报告.xlsx"},
@@ -570,14 +570,20 @@ func TestResolveIMResourceDownloadPath(t *testing.T) {
{name: "default path, no CD, unknown MIME", safePath: "file_xxx", contentType: "application/x-unknown", want: "file_xxx"},
{name: "default path, CD with dir component", safePath: "downloads/file_xxx", contentDisposition: `attachment; filename="report.xlsx"`, want: "downloads/report.xlsx"},
// User --output without extension: use CD filename's extension
{name: "user path no ext, CD with ext", safePath: "myfile", contentDisposition: `attachment; filename="server.pdf"`, userSpecifiedOutput: true, want: "myfile.pdf"},
{name: "user path no ext, CD no ext, MIME ext", safePath: "myfile", contentDisposition: `attachment; filename="noext"`, contentType: "image/png", userSpecifiedOutput: true, want: "myfile.png"},
{name: "user path no ext, no CD, MIME ext", safePath: "myfile", contentType: "image/jpeg", userSpecifiedOutput: true, want: "myfile.jpg"},
{name: "user path no ext, CD with ext", safePath: "myfile", contentDisposition: `attachment; filename="server.pdf"`, preserveBasename: true, want: "myfile.pdf"},
{name: "user path no ext, CD no ext, MIME ext", safePath: "myfile", contentDisposition: `attachment; filename="noext"`, contentType: "image/png", preserveBasename: true, want: "myfile.png"},
{name: "user path no ext, no CD, MIME ext", safePath: "myfile", contentType: "image/jpeg", preserveBasename: true, want: "myfile.jpg"},
// Batch --download-resources (preserveBasename=true): the file_key basename
// is kept and only the extension borrowed, so two resources whose servers
// return the SAME Content-Disposition filename still resolve to distinct
// paths instead of clobbering each other.
{name: "batch key A, shared CD filename", safePath: "lark-im-resources/file_aaa", contentDisposition: `attachment; filename="download.bin"`, preserveBasename: true, want: "lark-im-resources/file_aaa.bin"},
{name: "batch key B, shared CD filename", safePath: "lark-im-resources/file_bbb", contentDisposition: `attachment; filename="download.bin"`, preserveBasename: true, want: "lark-im-resources/file_bbb.bin"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveIMResourceDownloadPath(tt.safePath, tt.contentType, tt.contentDisposition, tt.userSpecifiedOutput)
got := resolveIMResourceDownloadPath(tt.safePath, tt.contentType, tt.contentDisposition, tt.preserveBasename)
if got != tt.want {
t.Fatalf("resolveIMResourceDownloadPath() = %q, want %q", got, tt.want)
}

View File

@@ -36,6 +36,7 @@ var ImChatMessageList = common.Shortcut{
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
downloadResourcesFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
@@ -61,6 +62,9 @@ var ImChatMessageList = common.Shortcut{
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned messages (including thread_replies expanded inline) in batches of up to 20. Pass --no-reactions to skip.")
}
if runtime.Bool("download-resources") {
d = d.Desc(downloadResourcesDryRunDesc)
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -127,19 +131,23 @@ var ImChatMessageList = common.Shortcut{
// serial contact requests during the FormatMessageItem loop.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)
downloadResources := runtime.Bool("download-resources")
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources))
}
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
convertlib.ExpandThreadRepliesWithResources(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit, downloadResources)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
if downloadResources {
enrichMessageResourceDownloads(runtime, messages)
}
outData := map[string]interface{}{
"messages": messages,

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
)
// imResourceDownloadDir is the fixed sub-directory under the current working
// directory where --download-resources writes fetched resources, so users can
// find and clean them up easily.
const imResourceDownloadDir = "lark-im-resources"
// downloadResourcesFlag opts into automatic resource download. It is shared by
// the three message-listing commands (+chat-messages-list, +messages-mget,
// +threads-messages-list); off by default so the default output contract and
// request count are unchanged.
var downloadResourcesFlag = common.Flag{
Name: "download-resources",
Type: "bool",
Desc: "download image/file/audio/video/media resources (and post-embedded, excluding stickers) into ./lark-im-resources/ (default: off; no extra requests when off)",
}
// downloadResourcesDryRunDesc is the dry-run declaration appended when
// --download-resources is set, mirroring how --no-reactions surfaces the
// reactions enrichment call.
const downloadResourcesDryRunDesc = "Resource download (--download-resources): each downloadable resource (image/file/audio/video/media + post-embedded, excluding stickers) is fetched via GET /open-apis/im/v1/messages/:message_id/resources/:file_key into ./lark-im-resources/ after formatting; deduped by (message_id, file_key) with bounded concurrency, and single-resource failures are isolated."
// resolveResourceDownloadPath builds the safe relative path under
// ./lark-im-resources/ for a resource file_key, reusing
// normalizeDownloadOutputPath so abnormal keys (path separators, traversal,
// absolute paths) are rejected (AC8).
func resolveResourceDownloadPath(fileKey string) (string, error) {
return normalizeDownloadOutputPath(fileKey, imResourceDownloadDir+"/"+fileKey)
}
// enrichMessageResourceDownloads downloads the resource refs extracted during
// formatting into ./lark-im-resources/, filling local_path/size_bytes back into
// each message's resources block. The download engine isolates single-resource
// failures (fail-silent + stderr warning), so the main message output is never
// blocked.
func enrichMessageResourceDownloads(runtime *common.RuntimeContext, messages []map[string]interface{}) {
convertlib.EnrichResourceDownloads(runtime, messages, func(dlCtx context.Context, messageID, key, fileType string) (string, int64, error) {
rel, err := resolveResourceDownloadPath(key)
if err != nil {
return "", 0, err
}
if _, err := runtime.ResolveSavePath(rel); err != nil {
return "", 0, err
}
// preserveBasename=true: each resource is keyed by its unique file_key,
// so keep that basename and only append an extension. Adopting the
// server's Content-Disposition filename here would let two resources
// that share a filename (e.g. download.bin) clobber each other.
return downloadIMResourceToPath(dlCtx, runtime, messageID, key, fileType, rel, true)
})
}

View File

@@ -30,6 +30,7 @@ var ImMessagesMGet = common.Shortcut{
Flags: []common.Flag{
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
downloadResourcesFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids := common.SplitCSV(runtime.Str("message-ids"))
@@ -38,6 +39,9 @@ var ImMessagesMGet = common.Shortcut{
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned messages in batches of up to 20 to attach the reactions block (operator, action_time, counts). Pass --no-reactions to skip.")
}
if runtime.Bool("download-resources") {
d = d.Desc(downloadResourcesDryRunDesc)
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -74,18 +78,22 @@ var ImMessagesMGet = common.Shortcut{
// contact API call.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)
downloadResources := runtime.Bool("download-resources")
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources))
}
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
convertlib.ExpandThreadRepliesWithResources(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit, downloadResources)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
if downloadResources {
enrichMessageResourceDownloads(runtime, messages)
}
outData := map[string]interface{}{
"messages": messages,

View File

@@ -73,8 +73,10 @@ var ImMessagesResourcesDownload = common.Shortcut{
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
userSpecifiedOutput := runtime.Str("output") != ""
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath, userSpecifiedOutput)
// With an explicit --output, keep that basename (append only an
// extension); without it, adopt the server's original filename.
preserveBasename := runtime.Str("output") != ""
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath, preserveBasename)
if err != nil {
return err
}
@@ -264,7 +266,7 @@ func initialIMResourceDownloadHeaders(fileType string) map[string]string {
}
}
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string, userSpecifiedOutput bool) (string, int64, error) {
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string, preserveBasename bool) (string, int64, error) {
downloadResp, err := doIMResourceDownloadRequest(ctx, runtime, messageID, fileKey, fileType, initialIMResourceDownloadHeaders(fileType))
if err != nil {
return "", 0, err
@@ -278,7 +280,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
return "", 0, downloadResponseError(downloadResp)
}
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"), downloadResp.Header.Get("Content-Disposition"), userSpecifiedOutput)
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"), downloadResp.Header.Get("Content-Disposition"), preserveBasename)
var (
body io.ReadCloser
@@ -321,20 +323,32 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
return savedPath, result.Size(), nil
}
func resolveIMResourceDownloadPath(safePath, contentType, contentDisposition string, userSpecifiedOutput bool) string {
// resolveIMResourceDownloadPath decides the on-disk path for a downloaded
// resource. preserveBasename controls how a server-provided
// Content-Disposition filename is used when safePath has no extension:
// - false: adopt the server's original filename (replace the basename) — the
// friendly single-file behavior for an explicit `+messages-resources-download`
// with no --output.
// - true: keep safePath's basename and only borrow the extension. Used both
// when the user pinned --output and for batch --download-resources, where
// safePath is keyed by the unique (file_key) and the basename MUST stay
// unique — otherwise two resources whose servers return the same
// Content-Disposition filename (e.g. download.bin) would resolve to the
// same path and clobber each other concurrently.
func resolveIMResourceDownloadPath(safePath, contentType, contentDisposition string, preserveBasename bool) string {
if filepath.Ext(safePath) != "" {
return safePath
}
if cdFilename := parseContentDispositionFilename(contentDisposition); cdFilename != "" {
if !userSpecifiedOutput {
// No --output flag: use the original filename from the server.
if !preserveBasename {
// Adopt the server's original filename.
dir := filepath.Dir(safePath)
if dir == "." {
return cdFilename
}
return filepath.Join(dir, cdFilename)
}
// User specified a path without extension: append the extension from the CD filename.
// Keep the basename; only append the extension from the CD filename.
if ext := filepath.Ext(cdFilename); ext != "" {
return safePath + ext
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import "testing"
// TestDownloadResourcePathSafety verifies the --download-resources path builder
// confines downloads to ./lark-im-resources/ and rejects abnormal file_keys
// (path separators, traversal, absolute paths) via the existing
// normalizeDownloadOutputPath guard (AC8).
func TestDownloadResourcePathSafety(t *testing.T) {
if rel, err := resolveResourceDownloadPath("file_123"); err != nil || rel != "lark-im-resources/file_123" {
t.Fatalf("resolveResourceDownloadPath(file_123) = (%q, %v), want (lark-im-resources/file_123, nil)", rel, err)
}
bad := []string{
"", // empty
"a/b", // forward slash
`a\b`, // backslash
"..", // traversal-only
"../etc", // traversal with slash
"/abs", // absolute-ish (leading slash)
}
for _, key := range bad {
if rel, err := resolveResourceDownloadPath(key); err == nil {
t.Fatalf("resolveResourceDownloadPath(%q) = (%q, nil), want rejection", key, rel)
}
}
}

View File

@@ -35,6 +35,7 @@ var ImThreadsMessagesList = common.Shortcut{
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
{Name: "page-token", Desc: "page token"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
downloadResourcesFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
threadFlag := runtime.Str("thread")
@@ -75,6 +76,9 @@ var ImThreadsMessagesList = common.Shortcut{
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
}
if runtime.Bool("download-resources") {
d = d.Desc(downloadResourcesDryRunDesc)
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -129,10 +133,11 @@ var ImThreadsMessagesList = common.Shortcut{
// in one batched contact API call.
mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache)
downloadResources := runtime.Bool("download-resources")
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, _ := item.(map[string]interface{})
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch))
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources))
}
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
@@ -141,6 +146,9 @@ var ImThreadsMessagesList = common.Shortcut{
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
if downloadResources {
enrichMessageResourceDownloads(runtime, messages)
}
outData := map[string]interface{}{
"thread_id": threadId,

View File

@@ -39,6 +39,10 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Opt-in resource auto-download (`--download-resources`)
`+chat-messages-list`, `+messages-mget`, and `+threads-messages-list` accept `--download-resources` (**off by default** — no `resources` block and no extra requests when omitted). When set, eligible message resources (image/file/audio/video/media + post-embedded; **stickers excluded**) are downloaded into `./lark-im-resources/` and each message gains a `resources` array of `{message_id, key, type, local_path, size_bytes}`. Downloads are deduped by `(message_id, file_key)`, run with bounded concurrency, and isolate single-resource failures (`error: true` + stderr warning). **Scope:** requires `im:message:readonly` (already declared by the listing commands — no extra scope); works under both user and bot identity. For one-off downloads use [`+messages-resources-download`](references/lark-im-messages-resources-download.md). Full contract: [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Card Messages (Interactive)
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.

View File

@@ -53,6 +53,10 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Opt-in resource auto-download (`--download-resources`)
`+chat-messages-list`, `+messages-mget`, and `+threads-messages-list` accept `--download-resources` (**off by default** — no `resources` block and no extra requests when omitted). When set, eligible message resources (image/file/audio/video/media + post-embedded; **stickers excluded**) are downloaded into `./lark-im-resources/` and each message gains a `resources` array of `{message_id, key, type, local_path, size_bytes}`. Downloads are deduped by `(message_id, file_key)`, run with bounded concurrency, and isolate single-resource failures (`error: true` + stderr warning). **Scope:** requires `im:message:readonly` (already declared by the listing commands — no extra scope); works under both user and bot identity. For one-off downloads use [`+messages-resources-download`](references/lark-im-messages-resources-download.md). Full contract: [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Card Messages (Interactive)
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.

View File

@@ -4,7 +4,7 @@
Fetch the message list for a conversation. Supports both group chats and direct messages.
By default the response carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Thread replies expanded via auto-`thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
By default the response carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Thread replies expanded via auto-`thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. Pass `--download-resources` to additionally download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block — off by default. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
This skill maps to the shortcut: `lark-cli im +chat-messages-list` (internally calls `GET /open-apis/im/v1/messages`, and automatically resolves the p2p chat_id when needed).
@@ -44,21 +44,26 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
| `--sort <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
| `--page-size <n>` | No | Page size (default 50, max 50) |
| `--page-token <token>` | No | Pagination token |
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default; no extra requests when omitted |
> Rule: `--chat-id` and `--user-id` are mutually exclusive. You must provide exactly one of them.
## Resource Rendering
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files and videos are rendered with resource keys in the content. Resource binaries are **not** downloaded automatically by this command.
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
Use [lark-im-messages-resources-download](lark-im-messages-resources-download.md) when you need to download an image or file from a specific message.
Two ways to get the binaries:
- **In one pass:** add `--download-resources` to this command — every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) is downloaded into `./lark-im-resources/` and a `resources` block (`{message_id, key, type, local_path, size_bytes}`) is attached to each message. See [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in).
- **One at a time:** use [lark-im-messages-resources-download](lark-im-messages-resources-download.md).
| Resource Type | Marker in Content | Behavior |
|---------|-------------|------|
| Image | `[Image: img_xxx]` | Download manually with `im +messages-resources-download --type image` |
| File | `<file key="file_xxx" .../>` | Download manually with `im +messages-resources-download --type file` |
| Audio | `<audio key="file_xxx" .../>` | Download manually with `im +messages-resources-download --type file` |
| Video | `<video key="file_xxx" .../>` | Download manually with `im +messages-resources-download --type file` |
| Image | `[Image: img_xxx]` | `--download-resources`, or manually `im +messages-resources-download --type image` |
| File | `<file key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Audio | `<audio key="file_xxx" duration="Xs"/>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Video | `<video key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Sticker | `[Sticker]` | Not downloadable (Feishu does not support fetching sticker resources) |
## Thread Expansion (`thread_id`)

View File

@@ -19,6 +19,21 @@ This is the single source of truth for the automatic message-enrichment contract
On per-thread fetch failure the host gets `thread_replies_error: true` (mirrors the reactions data contract); budget-truncated or budget-skipped threads do NOT carry that flag.
## Resource auto-download (`--download-resources`, opt-in)
`+chat-messages-list`, `+messages-mget`, and `+threads-messages-list` accept an **opt-in** `--download-resources` flag. It is **off by default** — when omitted, output and the request count are identical to before (no `resources` block, no extra round-trips).
When enabled:
- Each message that carries downloadable resources gets a `resources` array. Eligible types: `image`, `file`, `audio`, `video`, `media`, and post-embedded `img` / `media`. **Stickers are excluded** (Feishu does not support fetching sticker resources).
- Each ref is `{message_id, key, type, local_path, size_bytes}``type` is `image` or `file`; `message_id` is the id used to fetch the resource. For a standalone message that is its own id; for a resource inside a **merge_forward** it is the **top-level container** `message_id`, not the sub-item's own id (the download endpoint rejects sub-item ids with `234003 File not in msg` and can only fetch a forwarded resource through the container). Thread replies each get their own block.
- Files download into `./lark-im-resources/` under the current working directory. Each distinct `(message_id, file_key)` is downloaded once (deduped) with bounded concurrency (up to 3 in flight).
- **Fail-silent isolation**: a single resource that fails to download is flagged `"error": true` with one stderr line (`warning: resource_download_failed: <message_id>/<key>: ...`); the main message and the other resources are unaffected.
- Output paths are confined to `./lark-im-resources/` by the same guards as [`+messages-resources-download`](lark-im-messages-resources-download.md) (abnormal `file_key` with path separators / `..` / absolute paths is rejected).
- **Scope**: the download uses `GET /open-apis/im/v1/messages/:message_id/resources/:file_key`, which requires `im:message:readonly` — already declared in each listing command's `Scopes`, so `--download-resources` needs **no extra scope** beyond what's required to read the messages (user identity also needs `im:message.group_msg:get_as_user` / `im:message.p2p_msg:get_as_user`; bot identity needs `im:message.group_msg` / `im:message.p2p_msg:readonly`, all already declared). Works under both user and bot identity. If a bot was registered before `im:message:readonly` was granted, a single resource will fail-silently (`error: true` + stderr warning) rather than aborting the pull.
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `[Image: img_xxx]`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
## Scope requirement
The default enrichment requires `im:message.reactions:read`, already declared in each shortcut's `UserScopes` / `BotScopes` (or `Scopes` for the user-only search command), so the framework's pre-flight check surfaces a `missing_scope` error before the request is sent. Bots that were registered before this scope was added need an incremental authorization in the Feishu developer console; users can run:

View File

@@ -4,7 +4,7 @@
Fetch message details in batch. Given a list of message IDs, this returns the full content for multiple messages in one call and automatically resolves sender names.
By default the response also carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Replies inside `thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
By default the response also carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Replies inside `thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. Pass `--download-resources` to additionally download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block — off by default, no extra requests when omitted. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
> **Supports both `--as user` (default) and `--as bot`.**
@@ -31,6 +31,8 @@ lark-cli im +messages-mget --message-ids "om_aaa" --dry-run
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--message-ids <ids>` | Yes | At least one, max 50, `om_xxx` format, comma-separated | Message ID list |
| `--no-reactions` | No | — | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | — | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
## Output Fields

View File

@@ -4,7 +4,7 @@
Fetch the reply message list inside a thread. When `im +chat-messages-list` returns messages that include a `thread_id` field, use this command to inspect all replies in that thread.
By default each reply also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
By default each reply also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. Pass `--no-reactions` to skip the extra round-trip. Pass `--download-resources` to additionally download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block — off by default, no extra requests when omitted. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
This skill maps to the shortcut: `lark-cli im +threads-messages-list` (internally calls `GET /open-apis/im/v1/messages` with `container_id_type=thread` to fetch thread messages).
@@ -40,6 +40,8 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run
| Parameter | Required | Description |
|------|------|------|
| `--thread <id>` | Yes | Thread ID (`om_xxx` or `omt_xxx` format) |
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
| `--sort <order>` | No | Sort order: `asc` (default) / `desc` |
| `--page-size <n>` | No | Number of items per page (default 50, range 1-500) |
| `--page-token <token>` | No | Pagination token for the next page |
@@ -94,9 +96,9 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
## Resource Rendering
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `[Image: img_xxx]`; resource binaries are **not** downloaded automatically.
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `[Image: img_xxx]`; by default resource binaries are **not** downloaded.
Other resource types (files, audio, video) still need to be downloaded manually through `im +messages-resources-download`. See [lark-im-messages-resources-download](lark-im-messages-resources-download.md).
Pass `--download-resources` to download every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` in one pass and attach a `resources` block to each reply (see [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in)). Otherwise download individual resources manually through `im +messages-resources-download` (see [lark-im-messages-resources-download](lark-im-messages-resources-download.md)).
## Common Errors and Troubleshooting

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_DownloadResourcesDryRun verifies the --download-resources flag is wired
// into +chat-messages-list without breaking the existing request structure: the
// underlying GET /open-apis/im/v1/messages call is identical with or without the
// flag (AC4), and the flag only adds a resource-download declaration to dry-run.
func TestIM_DownloadResourcesDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
run := func(t *testing.T, extraArgs ...string) string {
t.Helper()
args := append([]string{
"im", "+chat-messages-list",
"--chat-id", "oc_dryrun",
"--no-reactions",
}, extraArgs...)
args = append(args, "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 0)
return result.Stdout
}
t.Run("default off: no resources declaration, request unchanged", func(t *testing.T) {
out := run(t)
require.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/im/v1/messages", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "oc_dryrun", gjson.Get(out, "api.0.params.container_id").String(), "stdout:\n%s", out)
require.NotContains(t, strings.ToLower(out), "lark-im-resources", "default must not declare resource download:\n%s", out)
})
t.Run("with --download-resources: request unchanged, declares download", func(t *testing.T) {
out := run(t, "--download-resources")
require.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/im/v1/messages", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "oc_dryrun", gjson.Get(out, "api.0.params.container_id").String(), "stdout:\n%s", out)
require.Contains(t, strings.ToLower(out), "lark-im-resources", "flag must declare resource download:\n%s", out)
})
}