diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index fbe085b73..4952394b7 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -84,3 +84,64 @@ func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) s content = mentionPlaceholderRegex.ReplaceAllString(content, "") return strings.TrimSpace(content) } + +// extractCardImageKeys recursively extracts all image keys from a Feishu interactive card. +// Image keys are used to download images from Feishu API. +// Returns two slices: Feishu-hosted keys and external URLs. +func extractCardImageKeys(rawContent string) (feishuKeys []string, externalURLs []string) { + if rawContent == "" { + return nil, nil + } + + var card map[string]any + if err := json.Unmarshal([]byte(rawContent), &card); err != nil { + return nil, nil + } + + extractImageKeysRecursive(card, &feishuKeys, &externalURLs) + return feishuKeys, externalURLs +} + +// isExternalURL returns true if the string is an external HTTP/HTTPS URL. +func isExternalURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// extractImageKeysRecursive traverses card structure to find all image keys. +// Collects both Feishu-hosted keys and external URLs separately. +func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]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 (always Feishu-hosted) + if imgKey, ok := val["img_key"].(string); ok && imgKey != "" { + *feishuKeys = append(*feishuKeys, imgKey) + } + // Check src - could be Feishu key or external URL + if src, ok := val["src"].(string); ok && src != "" { + if isExternalURL(src) { + *externalURLs = append(*externalURLs, src) + } else { + *feishuKeys = append(*feishuKeys, src) + } + } + case "icon": + // Icon elements use icon_key + if iconKey, ok := val["icon_key"].(string); ok && iconKey != "" { + *feishuKeys = append(*feishuKeys, iconKey) + } + } + } + // Recurse into all nested structures + for _, child := range val { + extractImageKeysRecursive(child, feishuKeys, externalURLs) + } + case []any: + for _, item := range val { + extractImageKeysRecursive(item, feishuKeys, externalURLs) + } + } +} diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go index fefc9f7c1..ff4af0148 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -290,3 +290,119 @@ func TestStripMentionPlaceholders(t *testing.T) { }) } } + +func TestExtractCardImageKeys(t *testing.T) { + tests := []struct { + name string + content string + wantFeishuKeys []string + wantExternalURLs []string + }{ + { + name: "empty content", + content: "", + wantFeishuKeys: nil, + wantExternalURLs: nil, + }, + { + name: "invalid JSON", + content: "not json", + wantFeishuKeys: nil, + wantExternalURLs: nil, + }, + { + name: "card with no images", + content: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"text"}]}}`, + wantFeishuKeys: nil, + wantExternalURLs: nil, + }, + { + name: "single image with img_key", + content: `{"elements":[{"tag":"img","img_key":"img_abc123"}]}`, + wantFeishuKeys: []string{"img_abc123"}, + wantExternalURLs: nil, + }, + { + name: "single image with src as Feishu key", + content: `{"elements":[{"tag":"img","src":"img_xyz789"}]}`, + wantFeishuKeys: []string{"img_xyz789"}, + wantExternalURLs: nil, + }, + { + name: "multiple images", + content: `{"elements":[{"tag":"img","img_key":"img_1"},{"tag":"div","text":{"content":"text"}},{"tag":"img","img_key":"img_2"}]}`, + wantFeishuKeys: []string{"img_1", "img_2"}, + wantExternalURLs: nil, + }, + { + name: "nested image in columns", + content: `{"elements":[{"tag":"div","columns":[{"tag":"img","img_key":"img_col1"},{"tag":"img","img_key":"img_col2"}]}]}`, + wantFeishuKeys: []string{"img_col1", "img_col2"}, + wantExternalURLs: nil, + }, + { + name: "image in action", + content: `{"elements":[{"tag":"action","actions":[{"tag":"img","img_key":"img_action"}]}]}`, + wantFeishuKeys: []string{"img_action"}, + wantExternalURLs: nil, + }, + { + name: "icon element", + content: `{"elements":[{"tag":"icon","icon_key":"icon_123"}]}`, + wantFeishuKeys: []string{"icon_123"}, + wantExternalURLs: nil, + }, + { + name: "complex card with text and images", + content: `{"header":{"title":{"content":"Title"}},"elements":[{"tag":"div","text":{"content":"Description"}},{"tag":"img","img_key":"img_main"}]}`, + wantFeishuKeys: []string{"img_main"}, + wantExternalURLs: nil, + }, + { + name: "external URL in src", + content: `{"elements":[{"tag":"img","src":"https://example.com/image.png"}]}`, + wantFeishuKeys: nil, + wantExternalURLs: []string{"https://example.com/image.png"}, + }, + { + name: "mixed Feishu keys and external URLs", + content: `{"elements":[{"tag":"img","img_key":"img_feishu"},{"tag":"img","src":"https://cdn.example.com/external.jpg"},{"tag":"img","src":"img_another"}]}`, + wantFeishuKeys: []string{"img_feishu", "img_another"}, + wantExternalURLs: []string{"https://cdn.example.com/external.jpg"}, + }, + { + name: "multiple external URLs", + content: `{"elements":[{"tag":"img","src":"https://a.com/1.png"},{"tag":"img","src":"http://b.com/2.jpg"}]}`, + wantFeishuKeys: nil, + wantExternalURLs: []string{"https://a.com/1.png", "http://b.com/2.jpg"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotFeishuKeys, gotExternalURLs := extractCardImageKeys(tt.content) + + // Compare Feishu keys + if len(gotFeishuKeys) != len(tt.wantFeishuKeys) { + t.Errorf("extractCardImageKeys() feishuKeys = %v, want %v", gotFeishuKeys, tt.wantFeishuKeys) + return + } + for i, v := range gotFeishuKeys { + if v != tt.wantFeishuKeys[i] { + t.Errorf("extractCardImageKeys() feishuKeys[%d] = %q, want %q", i, v, tt.wantFeishuKeys[i]) + } + } + + // Compare external URLs + if len(gotExternalURLs) != len(tt.wantExternalURLs) { + t.Errorf("extractCardImageKeys() externalURLs = %v, want %v", gotExternalURLs, tt.wantExternalURLs) + return + } + for i, v := range gotExternalURLs { + if v != tt.wantExternalURLs[i] { + t.Errorf("extractCardImageKeys() externalURLs[%d] = %q, want %q", i, v, tt.wantExternalURLs[i]) + } + } + }) + } +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 0341efc70..37a74718a 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -424,6 +424,15 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store) } + // For interactive cards, pass external image URLs via media refs. + // Keep content as valid raw JSON for downstream parsing. + if messageType == larkim.MsgTypeInteractive { + _, externalURLs := extractCardImageKeys(rawContent) + if len(externalURLs) > 0 { + mediaRefs = append(mediaRefs, externalURLs...) + } + } + // Append media tags to content (like Telegram does) content = appendMediaTags(content, messageType, mediaRefs) @@ -559,6 +568,10 @@ func extractContent(messageType, rawContent string) string { // Pass raw JSON to LLM — structured rich text is more informative than flattened plain text return rawContent + case larkim.MsgTypeInteractive: + // Pass raw JSON to LLM — structured card is more informative than flattened text + return rawContent + case larkim.MsgTypeImage: // Image messages don't have text content return "" @@ -596,6 +609,18 @@ func (c *FeishuChannel) downloadInboundMedia( refs = append(refs, ref) } + case larkim.MsgTypeInteractive: + // Extract and download images embedded in interactive cards + feishuKeys, _ := extractCardImageKeys(rawContent) + // Download Feishu-hosted images via API + for _, imageKey := range feishuKeys { + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } + } + // External URLs are passed directly to LLM, not downloaded + case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: fileKey := extractFileKey(rawContent) if fileKey == "" { @@ -716,11 +741,18 @@ func (c *FeishuChannel) downloadResource( } // appendMediaTags appends media type tags to content (like Telegram's "[image: photo]"). +// For interactive cards, media tags are not appended because content is raw JSON +// and appending would produce invalid JSON format. func appendMediaTags(content, messageType string, mediaRefs []string) string { if len(mediaRefs) == 0 { return content } + // Don't append tags to JSON content (interactive cards) - would produce invalid JSON + if messageType == larkim.MsgTypeInteractive { + return content + } + var tag string switch messageType { case larkim.MsgTypeImage: diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index dc3eab2e7..9010abf69 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -75,6 +75,24 @@ func TestExtractContent(t *testing.T) { rawContent: "", want: "", }, + { + name: "interactive card returns raw JSON", + messageType: "interactive", + rawContent: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`, + want: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`, + }, + { + name: "interactive card with complex structure returns raw JSON", + messageType: "interactive", + rawContent: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`, + want: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`, + }, + { + name: "interactive card invalid JSON returns as-is", + messageType: "interactive", + rawContent: `not valid json`, + want: `not valid json`, + }, } for _, tt := range tests { @@ -151,6 +169,13 @@ func TestAppendMediaTags(t *testing.T) { mediaRefs: []string{"ref1"}, want: "something [attachment]", }, + { + name: "interactive card with images returns content unchanged", + content: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, + messageType: "interactive", + mediaRefs: []string{"ref1"}, + want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, + }, } for _, tt := range tests {