From 7ff4d0ded8dc8b6ee04aa7da5d3a5f2da5f1257b Mon Sep 17 00:00:00 2001 From: ywj <138745068+yangwenjie1231@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:37:58 +0800 Subject: [PATCH] feat(feishu): add interactive card message parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for parsing inbound Feishu interactive card messages. When a user sends a card message, the text content is now extracted and passed to the LLM for processing. - Add extractCardText() to recursively extract text from card JSON - Support both JSON 1.0 (legacy) and JSON 2.0 schema formats - Handle nested elements: header, body, actions, columns - Extract text from markdown, lark_md, and plain_text elements - Add comprehensive unit tests for card parsing Fixes # 💘 Generated with Crush Assisted-by: GLM-5 via Crush --- pkg/channels/feishu/common.go | 94 ++++++++++++++++++++++ pkg/channels/feishu/common_test.go | 110 ++++++++++++++++++++++++++ pkg/channels/feishu/feishu_64.go | 4 + pkg/channels/feishu/feishu_64_test.go | 18 +++++ 4 files changed, 226 insertions(+) diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index fbe085b73..168362194 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -84,3 +84,97 @@ func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) s content = mentionPlaceholderRegex.ReplaceAllString(content, "") return strings.TrimSpace(content) } + +// extractCardText recursively extracts all text content from a Feishu interactive card. +// It handles both JSON 1.0 (legacy) and JSON 2.0 schema formats. +func extractCardText(rawContent string) string { + if rawContent == "" { + return "" + } + + var card map[string]any + if err := json.Unmarshal([]byte(rawContent), &card); err != nil { + return "" + } + + var texts []string + + // Extract header title + if header, ok := card["header"].(map[string]any); ok { + if title := extractTextFromElement(header["title"]); title != "" { + texts = append(texts, title) + } + } + + // JSON 2.0 schema: body.elements + if body, ok := card["body"].(map[string]any); ok { + if elements, ok := body["elements"].([]any); ok { + for _, elem := range elements { + extractTextFromElementsRecursive(elem, &texts) + } + } + } + + // JSON 1.0 schema: elements (legacy format) + if elements, ok := card["elements"].([]any); ok { + for _, elem := range elements { + extractTextFromElementsRecursive(elem, &texts) + } + } + + if len(texts) == 0 { + return "" + } + return strings.Join(texts, "\n") +} + +// extractTextFromElementsRecursive recursively traverses card elements to extract text. +func extractTextFromElementsRecursive(v any, texts *[]string) { + switch val := v.(type) { + case map[string]any: + // Check for text content in common fields + if text := extractTextFromElement(val); text != "" { + *texts = append(*texts, text) + } + // Recurse into nested structures + for key, child := range val { + switch key { + case "elements", "actions", "columns", "extra": + extractTextFromElementsRecursive(child, texts) + } + } + case []any: + for _, item := range val { + extractTextFromElementsRecursive(item, texts) + } + } +} + +// extractTextFromElement extracts text from a single card element. +func extractTextFromElement(elem any) string { + m, ok := elem.(map[string]any) + if !ok { + return "" + } + + // Direct content field (markdown element in JSON 2.0) + if content, ok := m["content"].(string); ok && content != "" { + return content + } + + // Text object with tag and content (lark_md, plain_text) + if text, ok := m["text"].(map[string]any); ok { + if content, ok := text["content"].(string); ok && content != "" { + return content + } + } + + // Title object with tag and content + if title, ok := m["title"].(map[string]any); ok { + if content, ok := title["content"].(string); ok && content != "" { + return content + } + } + + return "" +} diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go index fefc9f7c1..1859e5f78 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -290,3 +290,113 @@ func TestStripMentionPlaceholders(t *testing.T) { }) } } + +func TestExtractCardText(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "empty content", + content: "", + want: "", + }, + { + name: "invalid JSON", + content: "not json", + want: "", + }, + { + name: "JSON 2.0 schema with markdown element", + content: `{ + "schema": "2.0", + "body": { + "elements": [ + {"tag": "markdown", "content": "Hello **world**"} + ] + } + }`, + want: "Hello **world**", + }, + { + name: "JSON 2.0 schema with multiple elements", + content: `{ + "schema": "2.0", + "header": { + "title": {"tag": "plain_text", "content": "Card Title"} + }, + "body": { + "elements": [ + {"tag": "markdown", "content": "First paragraph"}, + {"tag": "markdown", "content": "Second paragraph"} + ] + } + }`, + want: "Card Title\nFirst paragraph\nSecond paragraph", + }, + { + name: "JSON 1.0 legacy format with div and lark_md", + content: `{ + "elements": [ + { + "tag": "div", + "text": {"tag": "lark_md", "content": "Content with **bold**"} + } + ] + }`, + want: "Content with **bold**", + }, + { + name: "nested elements in columns", + content: `{ + "elements": [ + { + "tag": "div", + "columns": [ + {"text": {"tag": "plain_text", "content": "Column 1"}}, + {"text": {"tag": "plain_text", "content": "Column 2"}} + ] + } + ] + }`, + want: "Column 1\nColumn 2", + }, + { + name: "action with buttons", + content: `{ + "elements": [ + { + "tag": "action", + "actions": [ + {"tag": "button", "text": {"tag": "plain_text", "content": "OK"}}, + {"tag": "button", "text": {"tag": "plain_text", "content": "Cancel"}} + ] + } + ] + }`, + want: "OK\nCancel", + }, + { + name: "card with no text content", + content: `{ + "schema": "2.0", + "body": { + "elements": [ + {"tag": "hr"} + ] + } + }`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractCardText(tt.content) + if got != tt.want { + t.Errorf("extractCardText() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5dbbcf0af..fdc47ca3f 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -507,6 +507,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: + // Extract text content from interactive card messages + return extractCardText(rawContent) + case larkim.MsgTypeImage: // Image messages don't have text content return "" diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index dc3eab2e7..d769fa231 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 with markdown content", + messageType: "interactive", + rawContent: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`, + want: "Hello from card", + }, + { + name: "interactive card with header and body", + messageType: "interactive", + rawContent: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`, + want: "Title\nCard content", + }, + { + name: "interactive card invalid JSON returns empty", + messageType: "interactive", + rawContent: `not valid json`, + want: "", + }, } for _, tt := range tests {