Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions shortcuts/im/convert_lib/content_convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -141,19 +141,30 @@ 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{})
deleted, _ := m["deleted"].(bool)
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),
Expand Down Expand Up @@ -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
}

Expand Down
73 changes: 73 additions & 0 deletions shortcuts/im/convert_lib/content_media_misc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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>"
Expand Down
10 changes: 10 additions & 0 deletions shortcuts/im/convert_lib/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
15 changes: 8 additions & 7 deletions shortcuts/im/convert_lib/reactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -204,19 +204,20 @@ 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)
}
}
}

// 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()
Expand Down
141 changes: 141 additions & 0 deletions shortcuts/im/convert_lib/resource_download.go
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 35 in shortcuts/im/convert_lib/resource_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/resource_download.go#L35

Added line #L35 was not covered by tests
}

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

Check warning on line 50 in shortcuts/im/convert_lib/resource_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/resource_download.go#L50

Added line #L50 was not covered by tests
}
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

Check warning on line 62 in shortcuts/im/convert_lib/resource_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/resource_download.go#L62

Added line #L62 was not covered by tests
}

ctx := runtime.Ctx()
var stderrMu sync.Mutex

download := func(rk refKey) {
if err := ctx.Err(); err != nil {
return

Check warning on line 70 in shortcuts/im/convert_lib/resource_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/resource_download.go#L70

Added line #L70 was not covered by tests
}
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)

Check warning on line 124 in shortcuts/im/convert_lib/resource_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/resource_download.go#L121-L124

Added lines #L121 - L124 were not covered by tests
}
}
}
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)

Check warning on line 135 in shortcuts/im/convert_lib/resource_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/resource_download.go#L131-L135

Added lines #L131 - L135 were not covered by tests
}
}
collectResourceRefs(typed, fn)

Check warning on line 138 in shortcuts/im/convert_lib/resource_download.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/im/convert_lib/resource_download.go#L138

Added line #L138 was not covered by tests
}
}
}
Loading
Loading