Skip to content

Commit 501bf53

Browse files
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
1 parent 8e667db commit 501bf53

26 files changed

Lines changed: 1238 additions & 61 deletions

shortcuts/im/convert_lib/content_convert.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
131131
if len(senderNames) > 0 {
132132
nameCache = senderNames[0]
133133
}
134-
return formatMessageItem(m, runtime, nameCache, nil)
134+
return formatMessageItem(m, runtime, nameCache, nil, false)
135135
}
136136

137137
// FormatMessageItemWithMergePrefetch is like FormatMessageItem but threads a
@@ -141,19 +141,30 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
141141
// items should pre-fetch once and call this variant in the loop to avoid the
142142
// N × ~1s serial-merge_forward stall in the original code path.
143143
func FormatMessageItemWithMergePrefetch(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
144-
return formatMessageItem(m, runtime, nameCache, mergePrefetch)
144+
return formatMessageItem(m, runtime, nameCache, mergePrefetch, false)
145145
}
146146

147-
func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} {
147+
// FormatMessageItemWithMergePrefetchOpts is FormatMessageItemWithMergePrefetch
148+
// with an explicit extractResources gate. When extractResources is true and
149+
// the message carries downloadable resources, a "resources" block (ref list
150+
// without local_path/size_bytes) is attached for the download enrichment stage
151+
// to fill. The other entry points are thin extractResources=false wrappers, so
152+
// default output is unchanged.
153+
func FormatMessageItemWithMergePrefetchOpts(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) map[string]interface{} {
154+
return formatMessageItem(m, runtime, nameCache, mergePrefetch, extractResources)
155+
}
156+
157+
func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) map[string]interface{} {
148158
msgType, _ := m["msg_type"].(string)
149159
messageId, _ := m["message_id"].(string)
150160
mentions, _ := m["mentions"].([]interface{})
151161
deleted, _ := m["deleted"].(bool)
152162
updated, _ := m["updated"].(bool)
153163

154164
content := ""
165+
rawContent := ""
155166
if body, ok := m["body"].(map[string]interface{}); ok {
156-
rawContent, _ := body["content"].(string)
167+
rawContent, _ = body["content"].(string)
157168
content = ConvertBodyContent(msgType, &ConvertContext{
158169
RawContent: rawContent,
159170
MentionMap: BuildMentionKeyMap(mentions),
@@ -232,6 +243,20 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
232243
msg["mentions"] = simplified
233244
}
234245

246+
if extractResources {
247+
if refs := ExtractResourceRefs(msgType, rawContent, messageId, mergePrefetch); len(refs) > 0 {
248+
resources := make([]map[string]interface{}, 0, len(refs))
249+
for _, r := range refs {
250+
resources = append(resources, map[string]interface{}{
251+
"message_id": r.MessageID,
252+
"key": r.Key,
253+
"type": r.Type,
254+
})
255+
}
256+
msg["resources"] = resources
257+
}
258+
}
259+
235260
return msg
236261
}
237262

shortcuts/im/convert_lib/content_media_misc_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,79 @@ func TestMiscConverters(t *testing.T) {
517517
}
518518
}
519519

520+
// TestFormatMessageItemResourcesGate verifies the resources block is only
521+
// emitted when extractResources is on; the default path (and back-compat
522+
// wrappers) must never add a resources key.
523+
func TestFormatMessageItemResourcesGate(t *testing.T) {
524+
raw := map[string]interface{}{
525+
"msg_type": "image",
526+
"message_id": "om_img",
527+
"create_time": "1710500000",
528+
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
529+
"body": map[string]interface{}{"content": `{"image_key":"img_99"}`},
530+
}
531+
532+
// Gate off via the back-compat wrapper.
533+
off := FormatMessageItemWithMergePrefetch(raw, nil, nil, nil)
534+
if _, ok := off["resources"]; ok {
535+
t.Fatalf("FormatMessageItemWithMergePrefetch should not emit resources, got %#v", off["resources"])
536+
}
537+
538+
// Gate off via plain FormatMessageItem.
539+
plain := FormatMessageItem(raw, nil)
540+
if _, ok := plain["resources"]; ok {
541+
t.Fatalf("FormatMessageItem should not emit resources, got %#v", plain["resources"])
542+
}
543+
544+
// Gate on.
545+
on := FormatMessageItemWithMergePrefetchOpts(raw, nil, nil, nil, true)
546+
resources, ok := on["resources"].([]map[string]interface{})
547+
if !ok || len(resources) != 1 {
548+
t.Fatalf("FormatMessageItemWithMergePrefetchOpts(extract=true) resources = %#v, want 1 ref", on["resources"])
549+
}
550+
r := resources[0]
551+
if r["message_id"] != "om_img" || r["key"] != "img_99" || r["type"] != "image" {
552+
t.Fatalf("resource ref = %#v, want {om_img,img_99,image}", r)
553+
}
554+
if _, ok := r["local_path"]; ok {
555+
t.Fatalf("extract stage must not set local_path yet, got %#v", r["local_path"])
556+
}
557+
}
558+
559+
func TestAudioConverterFileKey(t *testing.T) {
560+
tests := []struct {
561+
name string
562+
raw string
563+
want string
564+
}{
565+
{name: "key and duration", raw: `{"file_key":"audio_1","duration":3500}`, want: `<audio key="audio_1" duration="4s"/>`},
566+
{name: "key escaped", raw: `{"file_key":"a\"k","duration":2000}`, want: `<audio key="a\"k" duration="2s"/>`},
567+
{name: "key without duration", raw: `{"file_key":"audio_2"}`, want: `<audio key="audio_2"/>`},
568+
{name: "duration without key", raw: `{"duration":3500}`, want: "[Voice: 4s]"},
569+
{name: "neither key nor duration", raw: `{}`, want: "[Voice]"},
570+
{name: "invalid json", raw: `{invalid`, want: "[Invalid audio JSON]"},
571+
}
572+
573+
for _, tt := range tests {
574+
t.Run(tt.name, func(t *testing.T) {
575+
if got := (audioMsgConverter{}).Convert(&ConvertContext{RawContent: tt.raw}); got != tt.want {
576+
t.Fatalf("audioMsgConverter.Convert(%s) = %q, want %q", tt.name, got, tt.want)
577+
}
578+
})
579+
}
580+
}
581+
582+
// TestStickerUnchanged: DEC-001 default A keeps sticker rendering as [Sticker]
583+
// regardless of payload; sticker must never be enriched or downloaded.
584+
func TestStickerUnchanged(t *testing.T) {
585+
if got := (stickerConverter{}).Convert(nil); got != "[Sticker]" {
586+
t.Fatalf("stickerConverter.Convert(nil) = %q, want %q", got, "[Sticker]")
587+
}
588+
if got := (stickerConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"sticker_1"}`}); got != "[Sticker]" {
589+
t.Fatalf("stickerConverter.Convert(with key) = %q, want %q", got, "[Sticker]")
590+
}
591+
}
592+
520593
func TestTodoConverter(t *testing.T) {
521594
got := (todoConverter{}).Convert(&ConvertContext{RawContent: `{"task_id":"task_1","summary":{"title":"Finish report","content":[[{"tag":"text","text":"prepare slides"}]]},"due_time":"1710500000"}`})
522595
want := "<todo task_id=\"task_1\">\nFinish report\nprepare slides\nDue: " + formatTimestamp("1710500000") + "\n</todo>"

shortcuts/im/convert_lib/media.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,21 @@ func (fileConverter) Convert(ctx *ConvertContext) string {
3838

3939
type audioMsgConverter struct{}
4040

41+
// Convert renders an audio message: when body.content carries a file_key it
42+
// emits <audio key="..." duration="Xs"/> (duration omitted when absent);
43+
// otherwise it falls back to [Voice: Xs] (duration only) or [Voice].
4144
func (audioMsgConverter) Convert(ctx *ConvertContext) string {
4245
parsed, err := ParseJSONObject(ctx.RawContent)
4346
if err != nil {
4447
return invalidJSONPlaceholder("audio")
4548
}
49+
if key, _ := parsed["file_key"].(string); key != "" {
50+
result := fmt.Sprintf(`<audio key="%s"`, cardEscapeAttr(key))
51+
if dur, ok := parsed["duration"].(float64); ok && dur > 0 {
52+
result += fmt.Sprintf(` duration="%.0fs"`, dur/1000)
53+
}
54+
return result + "/>"
55+
}
4656
if dur, ok := parsed["duration"].(float64); ok && dur > 0 {
4757
return fmt.Sprintf("[Voice: %.0fs]", dur/1000)
4858
}

shortcuts/im/convert_lib/reactions.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
162162
map[string]interface{}{"queries": queries},
163163
)
164164
if err != nil {
165-
warnReactionsf(stderrMu, runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
165+
warnSyncf(stderrMu, runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
166166
markReactionsError(batchIDs, idIndex)
167167
return
168168
}
@@ -204,19 +204,20 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
204204
}
205205
}
206206
if len(failedIDs) > 0 {
207-
warnReactionsf(stderrMu, runtime.IO().ErrOut,
207+
warnSyncf(stderrMu, runtime.IO().ErrOut,
208208
"warning: reactions_partial_failed: %d message(s) failed (%v)\n",
209209
len(failedIDs), failedIDs)
210210
markReactionsError(failedIDs, idIndex)
211211
}
212212
}
213213
}
214214

215-
// warnReactionsf writes a stderr warning under the supplied mutex when one is
216-
// provided (multi-batch concurrent path), so concurrent goroutines can't
217-
// interleave partial lines. mu == nil means the caller is on the single-batch
218-
// fast path where no synchronization is needed.
219-
func warnReactionsf(mu *sync.Mutex, w io.Writer, format string, args ...interface{}) {
215+
// warnSyncf writes a stderr warning under the supplied mutex when one is
216+
// provided (multi-batch / multi-download concurrent paths), so concurrent
217+
// goroutines can't interleave partial lines. mu == nil means the caller is on a
218+
// single-item fast path where no synchronization is needed. It is domain-neutral
219+
// — shared by reactions batch query and resource download enrichment.
220+
func warnSyncf(mu *sync.Mutex, w io.Writer, format string, args ...interface{}) {
220221
if mu != nil {
221222
mu.Lock()
222223
defer mu.Unlock()
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package convertlib
5+
6+
import (
7+
"context"
8+
"sync"
9+
10+
"github.com/larksuite/cli/shortcuts/common"
11+
)
12+
13+
// resourceDownloadConcurrency caps in-flight resource downloads. Each download
14+
// is a GET plus a local disk write; capping at 3 keeps the
15+
// messages/{id}/resources/{key} endpoint well under any gateway-layer rate
16+
// ceiling while still cutting wall-clock versus a serial loop.
17+
const resourceDownloadConcurrency = 3
18+
19+
// ResourceDownloader downloads one resource and returns its local path and
20+
// size in bytes. messageID is the resource's owning message id (the download
21+
// API path parameter), key is the file_key/image_key, and fileType is the
22+
// download API resource type ("image" or "file"). A non-nil error means the
23+
// single resource failed; the engine isolates that failure (fail-silent).
24+
type ResourceDownloader func(ctx context.Context, messageID, key, fileType string) (string, int64, error)
25+
26+
// EnrichResourceDownloads walks every message node (including nested
27+
// thread_replies) for "resources" blocks attached during formatting, downloads
28+
// each distinct (message_id, key) once with bounded concurrency, and fills
29+
// local_path/size_bytes back into every ref sharing that key. A single
30+
// resource failing is isolated: its ref is flagged "error": true and a warning
31+
// is written to stderr, while the main message and the other resources are
32+
// unaffected (S2.STA-DES-P0-002 weak-dependency isolation).
33+
func EnrichResourceDownloads(runtime *common.RuntimeContext, messages []map[string]interface{}, dl ResourceDownloader) {
34+
if len(messages) == 0 || dl == nil {
35+
return
36+
}
37+
38+
type refKey struct {
39+
messageID string
40+
key string
41+
}
42+
groups := make(map[refKey][]map[string]interface{})
43+
types := make(map[refKey]string)
44+
var order []refKey
45+
46+
collectResourceRefs(messages, func(ref map[string]interface{}) {
47+
messageID, _ := ref["message_id"].(string)
48+
key, _ := ref["key"].(string)
49+
if messageID == "" || key == "" {
50+
return
51+
}
52+
rk := refKey{messageID: messageID, key: key}
53+
if _, seen := groups[rk]; !seen {
54+
order = append(order, rk)
55+
if t, _ := ref["type"].(string); t != "" {
56+
types[rk] = t
57+
}
58+
}
59+
groups[rk] = append(groups[rk], ref)
60+
})
61+
if len(order) == 0 {
62+
return
63+
}
64+
65+
ctx := runtime.Ctx()
66+
var stderrMu sync.Mutex
67+
68+
download := func(rk refKey) {
69+
if err := ctx.Err(); err != nil {
70+
return
71+
}
72+
localPath, size, err := dl(ctx, rk.messageID, rk.key, types[rk])
73+
if err != nil {
74+
warnSyncf(&stderrMu, runtime.IO().ErrOut,
75+
"warning: resource_download_failed: %s/%s: %v\n", rk.messageID, rk.key, err)
76+
for _, ref := range groups[rk] {
77+
ref["error"] = true
78+
}
79+
return
80+
}
81+
for _, ref := range groups[rk] {
82+
ref["local_path"] = localPath
83+
ref["size_bytes"] = size
84+
}
85+
}
86+
87+
// Single-resource fast path: no goroutine overhead, deterministic stderr.
88+
if len(order) == 1 {
89+
download(order[0])
90+
return
91+
}
92+
93+
// Bounded-concurrency fan-out. Each goroutine writes only to its own
94+
// (message_id, key) group's ref maps — distinct keys map to distinct ref
95+
// maps, so there is no shared mutable state besides the stderr mutex.
96+
sem := make(chan struct{}, resourceDownloadConcurrency)
97+
var wg sync.WaitGroup
98+
for _, rk := range order {
99+
wg.Add(1)
100+
sem <- struct{}{}
101+
go func() {
102+
defer wg.Done()
103+
defer func() { <-sem }()
104+
download(rk)
105+
}()
106+
}
107+
wg.Wait()
108+
}
109+
110+
// collectResourceRefs walks messages (and nested thread_replies) and invokes fn
111+
// for every resource ref map found in each node's "resources" block. Handles
112+
// both the typed []map[string]interface{} (in-memory, set by formatMessageItem)
113+
// and []interface{} (post JSON round-trip) shapes, mirroring collectMessageNodes.
114+
func collectResourceRefs(messages []map[string]interface{}, fn func(ref map[string]interface{})) {
115+
for _, msg := range messages {
116+
switch res := msg["resources"].(type) {
117+
case []map[string]interface{}:
118+
for _, ref := range res {
119+
fn(ref)
120+
}
121+
case []interface{}:
122+
for _, raw := range res {
123+
if ref, ok := raw.(map[string]interface{}); ok {
124+
fn(ref)
125+
}
126+
}
127+
}
128+
switch nested := msg["thread_replies"].(type) {
129+
case []map[string]interface{}:
130+
collectResourceRefs(nested, fn)
131+
case []interface{}:
132+
typed := make([]map[string]interface{}, 0, len(nested))
133+
for _, raw := range nested {
134+
if m, ok := raw.(map[string]interface{}); ok {
135+
typed = append(typed, m)
136+
}
137+
}
138+
collectResourceRefs(typed, fn)
139+
}
140+
}
141+
}

0 commit comments

Comments
 (0)