feat(feishu): add interactive card message parsing

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 #<issue_number>

💘 Generated with Crush

Assisted-by: GLM-5 via Crush <crush@charm.land>
This commit is contained in:
ywj 2026-03-14 10:37:58 +08:00
parent 96fd4e0519
commit 7ff4d0ded8
4 changed files with 226 additions and 0 deletions

View file

@ -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 ""
}

View file

@ -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)
}
})
}
}

View file

@ -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 ""

View file

@ -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 {