fix(feishu): extract images from post (rich text) messages

When users send text+image together in Feishu, the message type is
"post" (rich text) instead of "image". The image download logic only
handled MsgTypeImage, so images embedded in post messages were silently
ignored — the agent received raw JSON instead of actual image content.

- Add extractPostImageKeys() to parse img tags from post content
- Add extractPostText() to extract plain text from post content
- Handle MsgTypePost in downloadInboundMedia to download embedded images
- Handle MsgTypePost in appendMediaTags to add [image: photo] tag
This commit is contained in:
青柠 2026-03-10 23:22:16 +08:00
parent 110fc71349
commit 1b65531ac2
4 changed files with 182 additions and 6 deletions

View file

@ -62,6 +62,61 @@ func extractJSONStringField(content, field string) string {
// Format: {"image_key": "img_xxx"}
func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
// extractPostImageKeys extracts all image_key values from a Feishu post (rich text) message.
// Post content format: {"title":"...","content":[[{"tag":"img","image_key":"..."},...],...]}.
func extractPostImageKeys(rawContent string) []string {
var post struct {
Content [][]struct {
Tag string `json:"tag"`
ImageKey string `json:"image_key"`
} `json:"content"`
}
if err := json.Unmarshal([]byte(rawContent), &post); err != nil {
return nil
}
var keys []string
for _, paragraph := range post.Content {
for _, elem := range paragraph {
if elem.Tag == "img" && elem.ImageKey != "" {
keys = append(keys, elem.ImageKey)
}
}
}
return keys
}
// extractPostText extracts plain text from a Feishu post (rich text) message.
// Returns title and text elements joined by newlines. Falls back to rawContent on parse error.
func extractPostText(rawContent string) string {
var post struct {
Title string `json:"title"`
Content [][]struct {
Tag string `json:"tag"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal([]byte(rawContent), &post); err != nil {
return rawContent
}
var parts []string
if post.Title != "" {
parts = append(parts, post.Title)
}
for _, paragraph := range post.Content {
for _, elem := range paragraph {
if elem.Tag == "text" && elem.Text != "" {
parts = append(parts, elem.Text)
}
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "\n")
}
// extractFileKey extracts the file_key from a Feishu file/audio message content JSON.
// Format: {"file_key": "file_xxx", "file_name": "...", ...}
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }

View file

@ -134,6 +134,93 @@ func TestExtractFileKey(t *testing.T) {
}
}
func TestExtractPostImageKeys(t *testing.T) {
tests := []struct {
name string
rawContent string
want []string
}{
{
name: "text and image",
rawContent: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_abc","width":100,"height":100}],[{"tag":"text","text":"hello","style":[]}]]}`,
want: []string{"img_v3_abc"},
},
{
name: "multiple images",
rawContent: `{"title":"","content":[[{"tag":"img","image_key":"img_1","width":100,"height":100},{"tag":"img","image_key":"img_2","width":100,"height":100}]]}`,
want: []string{"img_1", "img_2"},
},
{
name: "no images",
rawContent: `{"title":"","content":[[{"tag":"text","text":"hello","style":[]}]]}`,
want: nil,
},
{
name: "invalid JSON",
rawContent: `not json`,
want: nil,
},
{
name: "empty content",
rawContent: `{"title":"","content":[]}`,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPostImageKeys(tt.rawContent)
if len(got) != len(tt.want) {
t.Errorf("extractPostImageKeys() = %v, want %v", got, tt.want)
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("extractPostImageKeys()[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
func TestExtractPostText(t *testing.T) {
tests := []struct {
name string
rawContent string
want string
}{
{
name: "text and image",
rawContent: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_abc","width":100,"height":100}],[{"tag":"text","text":"图里有啥","style":[]}]]}`,
want: "图里有啥",
},
{
name: "with title",
rawContent: `{"title":"My Post","content":[[{"tag":"text","text":"body text","style":[]}]]}`,
want: "My Post\nbody text",
},
{
name: "only images returns empty",
rawContent: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_abc","width":100,"height":100}]]}`,
want: "",
},
{
name: "invalid JSON returns raw",
rawContent: `not json`,
want: "not json",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPostText(tt.rawContent)
if got != tt.want {
t.Errorf("extractPostText(%q) = %q, want %q", tt.rawContent, got, tt.want)
}
})
}
}
func TestExtractFileName(t *testing.T) {
tests := []struct {
name string

View file

@ -504,8 +504,8 @@ func extractContent(messageType, rawContent string) string {
return rawContent
case larkim.MsgTypePost:
// Pass raw JSON to LLM — structured rich text is more informative than flattened plain text
return rawContent
// Extract plain text from post; images are handled separately via downloadInboundMedia.
return extractPostText(rawContent)
case larkim.MsgTypeImage:
// Image messages don't have text content
@ -544,6 +544,14 @@ func (c *FeishuChannel) downloadInboundMedia(
refs = append(refs, ref)
}
case larkim.MsgTypePost:
for _, imageKey := range extractPostImageKeys(rawContent) {
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 == "" {
@ -670,7 +678,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
var tag string
switch messageType {
case larkim.MsgTypeImage:
case larkim.MsgTypeImage, larkim.MsgTypePost:
tag = "[image: photo]"
case larkim.MsgTypeAudio:
tag = "[audio]"

View file

@ -28,10 +28,22 @@ func TestExtractContent(t *testing.T) {
want: "not json",
},
{
name: "post message returns raw JSON",
name: "post message extracts text",
messageType: "post",
rawContent: `{"title": "test post"}`,
want: `{"title": "test post"}`,
rawContent: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_xxx","width":100,"height":100}],[{"tag":"text","text":"图里有啥","style":[]}]]}`,
want: "图里有啥",
},
{
name: "post message with title",
messageType: "post",
rawContent: `{"title":"test post","content":[[{"tag":"text","text":"hello","style":[]}]]}`,
want: "test post\nhello",
},
{
name: "post message only images returns empty",
messageType: "post",
rawContent: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_xxx","width":100,"height":100}]]}`,
want: "",
},
{
name: "image message returns empty",
@ -144,6 +156,20 @@ func TestAppendMediaTags(t *testing.T) {
mediaRefs: []string{"ref1"},
want: "report.pdf [file]",
},
{
name: "post with image refs",
content: "图里有啥",
messageType: "post",
mediaRefs: []string{"ref1"},
want: "图里有啥 [image: photo]",
},
{
name: "post empty content with image refs",
content: "",
messageType: "post",
mediaRefs: []string{"ref1"},
want: "[image: photo]",
},
{
name: "unknown type",
content: "something",