diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index fbe085b73..1389231ce 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -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") } diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go index fefc9f7c1..e4dd1a330 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -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 diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5dbbcf0af..7ed441a88 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -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]" diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index dc3eab2e7..9bf62ed1e 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -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",