mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
141
shortcuts/im/convert_lib/resource_download.go
Normal file
141
shortcuts/im/convert_lib/resource_download.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
219
shortcuts/im/convert_lib/resource_download_test.go
Normal file
219
shortcuts/im/convert_lib/resource_download_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
161
shortcuts/im/convert_lib/resource_extract.go
Normal file
161
shortcuts/im/convert_lib/resource_extract.go
Normal 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
|
||||
}
|
||||
108
shortcuts/im/convert_lib/resource_extract_test.go
Normal file
108
shortcuts/im/convert_lib/resource_extract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
61
shortcuts/im/im_download_resources.go
Normal file
61
shortcuts/im/im_download_resources.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
30
shortcuts/im/im_resource_download_test.go
Normal file
30
shortcuts/im/im_resource_download_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
59
tests/cli_e2e/im/im_download_resources_dryrun_test.go
Normal file
59
tests/cli_e2e/im/im_download_resources_dryrun_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user