From 622e11b9293a90f4b321d6ec05813b7736b1782a Mon Sep 17 00:00:00 2001 From: ywj <138745068+yangwenjie1231@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:44:30 +0800 Subject: [PATCH] feat(feishu): extract and download images from interactive cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When receiving interactive card messages, extract embedded images (img_key, src, icon_key) and download them for LLM processing. - Add extractCardImageKeys() to recursively extract image keys from card JSON - Support img elements (img_key, src) and icon elements (icon_key) - Update downloadInboundMedia() to handle MsgTypeInteractive - Add comprehensive unit tests for image extraction Images are downloaded and stored via MediaStore, then appended to the message content as [image: photo] tags for LLM visibility. 💘 Generated with Crush Assisted-by: GLM-5 via Crush --- pkg/channels/feishu/common.go | 51 ++++++++++++++++++++ pkg/channels/feishu/common_test.go | 74 ++++++++++++++++++++++++++++++ pkg/channels/feishu/feishu_64.go | 10 ++++ 3 files changed, 135 insertions(+) diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index 168362194..b1dc17a44 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -178,3 +178,54 @@ func extractTextFromElement(elem any) string { return "" } + +// extractCardImageKeys recursively extracts all image keys from a Feishu interactive card. +// Image keys are used to download images from Feishu API. +func extractCardImageKeys(rawContent string) []string { + if rawContent == "" { + return nil + } + + var card map[string]any + if err := json.Unmarshal([]byte(rawContent), &card); err != nil { + return nil + } + + var keys []string + extractImageKeysRecursive(card, &keys) + return keys +} + +// extractImageKeysRecursive traverses card structure to find all image keys. +func extractImageKeysRecursive(v any, keys *[]string) { + switch val := v.(type) { + case map[string]any: + // Check if this is an img element + if tag, ok := val["tag"].(string); ok { + switch tag { + case "img": + // Try img_key first (most common) + if imgKey, ok := val["img_key"].(string); ok && imgKey != "" { + *keys = append(*keys, imgKey) + } + // Also try src (alternative format) + if src, ok := val["src"].(string); ok && src != "" { + *keys = append(*keys, src) + } + case "icon": + // Icon elements use icon_key + if iconKey, ok := val["icon_key"].(string); ok && iconKey != "" { + *keys = append(*keys, iconKey) + } + } + } + // Recurse into all nested structures + for _, child := range val { + extractImageKeysRecursive(child, keys) + } + case []any: + for _, item := range val { + extractImageKeysRecursive(item, keys) + } + } +} diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go index 1859e5f78..39e718d82 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -400,3 +400,77 @@ func TestExtractCardText(t *testing.T) { }) } } + +func TestExtractCardImageKeys(t *testing.T) { + tests := []struct { + name string + content string + want []string + }{ + { + name: "empty content", + content: "", + want: nil, + }, + { + name: "invalid JSON", + content: "not json", + want: nil, + }, + { + name: "card with no images", + content: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"text"}]}}`, + want: nil, + }, + { + name: "single image with img_key", + content: `{"elements":[{"tag":"img","img_key":"img_abc123"}]}`, + want: []string{"img_abc123"}, + }, + { + name: "single image with src", + content: `{"elements":[{"tag":"img","src":"img_xyz789"}]}`, + want: []string{"img_xyz789"}, + }, + { + name: "multiple images", + content: `{"elements":[{"tag":"img","img_key":"img_1"},{"tag":"div","text":{"content":"text"}},{"tag":"img","img_key":"img_2"}]}`, + want: []string{"img_1", "img_2"}, + }, + { + name: "nested image in columns", + content: `{"elements":[{"tag":"div","columns":[{"tag":"img","img_key":"img_col1"},{"tag":"img","img_key":"img_col2"}]}]}`, + want: []string{"img_col1", "img_col2"}, + }, + { + name: "image in action", + content: `{"elements":[{"tag":"action","actions":[{"tag":"img","img_key":"img_action"}]}]}`, + want: []string{"img_action"}, + }, + { + name: "icon element", + content: `{"elements":[{"tag":"icon","icon_key":"icon_123"}]}`, + want: []string{"icon_123"}, + }, + { + name: "complex card with text and images", + content: `{"header":{"title":{"content":"Title"}},"elements":[{"tag":"div","text":{"content":"Description"}},{"tag":"img","img_key":"img_main"}]}`, + want: []string{"img_main"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractCardImageKeys(tt.content) + if len(got) != len(tt.want) { + t.Errorf("extractCardImageKeys() = %v, want %v", got, tt.want) + return + } + for i, v := range got { + if v != tt.want[i] { + t.Errorf("extractCardImageKeys()[%d] = %q, want %q", i, v, tt.want[i]) + } + } + }) + } +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index fdc47ca3f..54bfa82f9 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -548,6 +548,16 @@ func (c *FeishuChannel) downloadInboundMedia( refs = append(refs, ref) } + case larkim.MsgTypeInteractive: + // Extract and download images embedded in interactive cards + imageKeys := extractCardImageKeys(rawContent) + for _, imageKey := range imageKeys { + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } + } + case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: fileKey := extractFileKey(rawContent) if fileKey == "" {