feat(feishu): extract and download images from interactive cards
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 <crush@charm.land>
This commit is contained in:
parent
7ff4d0ded8
commit
622e11b929
3 changed files with 135 additions and 0 deletions
|
|
@ -178,3 +178,54 @@ func extractTextFromElement(elem any) string {
|
||||||
|
|
||||||
return ""
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -548,6 +548,16 @@ func (c *FeishuChannel) downloadInboundMedia(
|
||||||
refs = append(refs, ref)
|
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:
|
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
|
||||||
fileKey := extractFileKey(rawContent)
|
fileKey := extractFileKey(rawContent)
|
||||||
if fileKey == "" {
|
if fileKey == "" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue