diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go
index 1ede9b79e..1292b7d33 100644
--- a/shortcuts/im/convert_lib/content_convert.go
+++ b/shortcuts/im/convert_lib/content_convert.go
@@ -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
}
diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go
index 3d0dbdaea..b6b2be25d 100644
--- a/shortcuts/im/convert_lib/content_media_misc_test.go
+++ b/shortcuts/im/convert_lib/content_media_misc_test.go
@@ -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: ``},
+ {name: "key escaped", raw: `{"file_key":"a\"k","duration":2000}`, want: ``},
+ {name: "key without duration", raw: `{"file_key":"audio_2"}`, want: ``},
+ {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 := "\nFinish report\nprepare slides\nDue: " + formatTimestamp("1710500000") + "\n"
diff --git a/shortcuts/im/convert_lib/media.go b/shortcuts/im/convert_lib/media.go
index c1187a266..2bac70dc4 100644
--- a/shortcuts/im/convert_lib/media.go
+++ b/shortcuts/im/convert_lib/media.go
@@ -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 (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(`