2026-03-03 08:43:04 +00:00
|
|
|
package feishu
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestExtractJSONStringField(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
content string
|
|
|
|
|
field string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "valid field",
|
|
|
|
|
content: `{"image_key": "img_v2_xxx"}`,
|
|
|
|
|
field: "image_key",
|
|
|
|
|
want: "img_v2_xxx",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "missing field",
|
|
|
|
|
content: `{"image_key": "img_v2_xxx"}`,
|
|
|
|
|
field: "file_key",
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "invalid JSON",
|
|
|
|
|
content: `not json at all`,
|
|
|
|
|
field: "image_key",
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty content",
|
|
|
|
|
content: "",
|
|
|
|
|
field: "image_key",
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "non-string field value",
|
|
|
|
|
content: `{"count": 42}`,
|
|
|
|
|
field: "count",
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty string value",
|
|
|
|
|
content: `{"image_key": ""}`,
|
|
|
|
|
field: "image_key",
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "multiple fields",
|
|
|
|
|
content: `{"file_key": "file_xxx", "file_name": "test.pdf"}`,
|
|
|
|
|
field: "file_name",
|
|
|
|
|
want: "test.pdf",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got := extractJSONStringField(tt.content, tt.field)
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("extractJSONStringField(%q, %q) = %q, want %q", tt.content, tt.field, got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExtractImageKey(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
content string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "normal",
|
|
|
|
|
content: `{"image_key": "img_v2_abc123"}`,
|
|
|
|
|
want: "img_v2_abc123",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "missing key",
|
|
|
|
|
content: `{"file_key": "file_xxx"}`,
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "malformed JSON",
|
|
|
|
|
content: `{broken`,
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got := extractImageKey(tt.content)
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("extractImageKey(%q) = %q, want %q", tt.content, got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExtractFileKey(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
content string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "normal",
|
|
|
|
|
content: `{"file_key": "file_v2_abc123", "file_name": "test.doc"}`,
|
|
|
|
|
want: "file_v2_abc123",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "missing key",
|
|
|
|
|
content: `{"image_key": "img_xxx"}`,
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "malformed JSON",
|
|
|
|
|
content: `not json`,
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got := extractFileKey(tt.content)
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("extractFileKey(%q) = %q, want %q", tt.content, got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExtractFileName(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
content string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "normal",
|
|
|
|
|
content: `{"file_key": "file_xxx", "file_name": "report.pdf"}`,
|
|
|
|
|
want: "report.pdf",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "missing name",
|
|
|
|
|
content: `{"file_key": "file_xxx"}`,
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "malformed JSON",
|
|
|
|
|
content: `{bad`,
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got := extractFileName(tt.content)
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("extractFileName(%q) = %q, want %q", tt.content, got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildMarkdownCard(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
content string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "normal content",
|
|
|
|
|
content: "Hello **world**",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty content",
|
|
|
|
|
content: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "special characters",
|
|
|
|
|
content: `Code: "foo" & <bar> 'baz'`,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
result, err := buildMarkdownCard(tt.content)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("buildMarkdownCard(%q) unexpected error: %v", tt.content, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify valid JSON
|
|
|
|
|
var parsed map[string]any
|
|
|
|
|
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
|
|
|
|
t.Fatalf("buildMarkdownCard(%q) produced invalid JSON: %v", tt.content, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify schema
|
|
|
|
|
if parsed["schema"] != "2.0" {
|
|
|
|
|
t.Errorf("schema = %v, want %q", parsed["schema"], "2.0")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify body.elements[0].content == input
|
|
|
|
|
body, ok := parsed["body"].(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("missing body in card JSON")
|
|
|
|
|
}
|
|
|
|
|
elements, ok := body["elements"].([]any)
|
|
|
|
|
if !ok || len(elements) == 0 {
|
|
|
|
|
t.Fatal("missing or empty elements in card JSON")
|
|
|
|
|
}
|
|
|
|
|
elem, ok := elements[0].(map[string]any)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("first element is not an object")
|
|
|
|
|
}
|
|
|
|
|
if elem["tag"] != "markdown" {
|
|
|
|
|
t.Errorf("tag = %v, want %q", elem["tag"], "markdown")
|
|
|
|
|
}
|
|
|
|
|
if elem["content"] != tt.content {
|
|
|
|
|
t.Errorf("content = %v, want %q", elem["content"], tt.content)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestStripMentionPlaceholders(t *testing.T) {
|
|
|
|
|
strPtr := func(s string) *string { return &s }
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
content string
|
|
|
|
|
mentions []*larkim.MentionEvent
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "no mentions",
|
|
|
|
|
content: "Hello world",
|
|
|
|
|
mentions: nil,
|
|
|
|
|
want: "Hello world",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "single mention",
|
|
|
|
|
content: "@_user_1 hello",
|
|
|
|
|
mentions: []*larkim.MentionEvent{
|
|
|
|
|
{Key: strPtr("@_user_1")},
|
|
|
|
|
},
|
|
|
|
|
want: "hello",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "multiple mentions",
|
|
|
|
|
content: "@_user_1 @_user_2 hey",
|
|
|
|
|
mentions: []*larkim.MentionEvent{
|
|
|
|
|
{Key: strPtr("@_user_1")},
|
|
|
|
|
{Key: strPtr("@_user_2")},
|
|
|
|
|
},
|
|
|
|
|
want: "hey",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty content",
|
|
|
|
|
content: "",
|
|
|
|
|
mentions: []*larkim.MentionEvent{{Key: strPtr("@_user_1")}},
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty mentions slice",
|
|
|
|
|
content: "@_user_1 test",
|
|
|
|
|
mentions: []*larkim.MentionEvent{},
|
|
|
|
|
want: "@_user_1 test",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "mention with nil key",
|
|
|
|
|
content: "@_user_1 test",
|
|
|
|
|
mentions: []*larkim.MentionEvent{
|
|
|
|
|
{Key: nil},
|
|
|
|
|
},
|
|
|
|
|
want: "test",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got := stripMentionPlaceholders(tt.content, tt.mentions)
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("stripMentionPlaceholders(%q, ...) = %q, want %q", tt.content, got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
Feat/feishu card parsing (#1534)
* 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>
* 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>
* fix(feishu): simplify card parsing - pass raw JSON, only extract images
Address review feedback: text extraction cannot exhaustively handle all
card formats (i18n_elements, div.fields, etc.). Pass raw JSON to LLM
instead - same approach as MsgTypePost. Only image extraction remains
as images must be downloaded for LLM to process.
- Remove extractCardText() and helper functions
- extractContent() now returns raw JSON for MsgTypeInteractive
- Keep extractCardImageKeys() for downloading embedded images
- Update tests to expect raw JSON for interactive cards
* fix(feishu): don't append media tags to interactive card JSON
Appending media tags like "[attachment]" to raw JSON content produces
invalid JSON format. For interactive cards, the JSON already contains
image information and media refs are downloaded separately.
- Skip appendMediaTags for MsgTypeInteractive to preserve valid JSON
- Add test case for interactive card with images
* fix(feishu): filter out external URLs from card image extraction
Only Feishu-hosted image keys (img_xxx, icon_xxx) can be downloaded via
the Feishu API. External URLs in src field (https://...) should be
filtered out to avoid download failures.
- Add isFeishuImageKey() to detect Feishu-hosted keys vs external URLs
- Update extractImageKeysRecursive to skip external URLs in src field
- Add tests for external URL filtering and mixed scenarios
* feat(feishu): support downloading external images from interactive cards
Previously only Feishu-hosted images (img_key, icon_key) could be
downloaded. Now external URLs in src field are also downloaded via
HTTP and made available to the LLM.
- extractCardImageKeys now returns two slices: Feishu keys and external URLs
- Add downloadExternalImage to download images from HTTP URLs
- Update downloadInboundMedia to handle both Feishu API and HTTP downloads
- Update tests for new function signature
* fix(feishu): use HTTP client with timeout for external image downloads
Replaced http.DefaultClient with a client that has a 30-second timeout
to prevent hanging on unresponsive external URLs.
Generated with Crush
Assisted-by: GLM-5 via Crush <crush@charm.land>
* fix(feishu): resolve lint errors for shadow and formatting
- Rename err variables to avoid shadowing in downloadExternalImage
- Fix struct field alignment in TestExtractCardImageKeys
Generated with Crush
Assisted-by: GLM-5 via Crush <crush@charm.land>
* refactor(feishu): pass external image URLs to LLM instead of downloading
Instead of downloading external images from interactive cards, pass
the URLs directly to LLM. This reduces network overhead and lets
vision-capable models fetch images as needed.
- Remove downloadExternalImage function
- Append external URLs to card content for LLM processing
- Only download Feishu-hosted images via API
💘 Generated with Crush
Assisted-by: GLM-5 via Crush <crush@charm.land>
* fix(feishu): add blank line between functions for gci formatting
* fix(feishu): keep interactive card content as valid JSON
2026-03-20 04:59:43 +00:00
|
|
|
|
|
|
|
|
func TestExtractCardImageKeys(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
content string
|
|
|
|
|
wantFeishuKeys []string
|
|
|
|
|
wantExternalURLs []string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "empty content",
|
|
|
|
|
content: "",
|
|
|
|
|
wantFeishuKeys: nil,
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "invalid JSON",
|
|
|
|
|
content: "not json",
|
|
|
|
|
wantFeishuKeys: nil,
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "card with no images",
|
|
|
|
|
content: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"text"}]}}`,
|
|
|
|
|
wantFeishuKeys: nil,
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "single image with img_key",
|
|
|
|
|
content: `{"elements":[{"tag":"img","img_key":"img_abc123"}]}`,
|
|
|
|
|
wantFeishuKeys: []string{"img_abc123"},
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "single image with src as Feishu key",
|
|
|
|
|
content: `{"elements":[{"tag":"img","src":"img_xyz789"}]}`,
|
|
|
|
|
wantFeishuKeys: []string{"img_xyz789"},
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "multiple images",
|
|
|
|
|
content: `{"elements":[{"tag":"img","img_key":"img_1"},{"tag":"div","text":{"content":"text"}},{"tag":"img","img_key":"img_2"}]}`,
|
|
|
|
|
wantFeishuKeys: []string{"img_1", "img_2"},
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "nested image in columns",
|
|
|
|
|
content: `{"elements":[{"tag":"div","columns":[{"tag":"img","img_key":"img_col1"},{"tag":"img","img_key":"img_col2"}]}]}`,
|
|
|
|
|
wantFeishuKeys: []string{"img_col1", "img_col2"},
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "image in action",
|
|
|
|
|
content: `{"elements":[{"tag":"action","actions":[{"tag":"img","img_key":"img_action"}]}]}`,
|
|
|
|
|
wantFeishuKeys: []string{"img_action"},
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "icon element",
|
|
|
|
|
content: `{"elements":[{"tag":"icon","icon_key":"icon_123"}]}`,
|
|
|
|
|
wantFeishuKeys: []string{"icon_123"},
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "complex card with text and images",
|
|
|
|
|
content: `{"header":{"title":{"content":"Title"}},"elements":[{"tag":"div","text":{"content":"Description"}},{"tag":"img","img_key":"img_main"}]}`,
|
|
|
|
|
wantFeishuKeys: []string{"img_main"},
|
|
|
|
|
wantExternalURLs: nil,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "external URL in src",
|
|
|
|
|
content: `{"elements":[{"tag":"img","src":"https://example.com/image.png"}]}`,
|
|
|
|
|
wantFeishuKeys: nil,
|
|
|
|
|
wantExternalURLs: []string{"https://example.com/image.png"},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "mixed Feishu keys and external URLs",
|
|
|
|
|
content: `{"elements":[{"tag":"img","img_key":"img_feishu"},{"tag":"img","src":"https://cdn.example.com/external.jpg"},{"tag":"img","src":"img_another"}]}`,
|
|
|
|
|
wantFeishuKeys: []string{"img_feishu", "img_another"},
|
|
|
|
|
wantExternalURLs: []string{"https://cdn.example.com/external.jpg"},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "multiple external URLs",
|
|
|
|
|
content: `{"elements":[{"tag":"img","src":"https://a.com/1.png"},{"tag":"img","src":"http://b.com/2.jpg"}]}`,
|
|
|
|
|
wantFeishuKeys: nil,
|
|
|
|
|
wantExternalURLs: []string{"https://a.com/1.png", "http://b.com/2.jpg"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
gotFeishuKeys, gotExternalURLs := extractCardImageKeys(tt.content)
|
|
|
|
|
|
|
|
|
|
// Compare Feishu keys
|
|
|
|
|
if len(gotFeishuKeys) != len(tt.wantFeishuKeys) {
|
|
|
|
|
t.Errorf("extractCardImageKeys() feishuKeys = %v, want %v", gotFeishuKeys, tt.wantFeishuKeys)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for i, v := range gotFeishuKeys {
|
|
|
|
|
if v != tt.wantFeishuKeys[i] {
|
|
|
|
|
t.Errorf("extractCardImageKeys() feishuKeys[%d] = %q, want %q", i, v, tt.wantFeishuKeys[i])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Compare external URLs
|
|
|
|
|
if len(gotExternalURLs) != len(tt.wantExternalURLs) {
|
|
|
|
|
t.Errorf("extractCardImageKeys() externalURLs = %v, want %v", gotExternalURLs, tt.wantExternalURLs)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for i, v := range gotExternalURLs {
|
|
|
|
|
if v != tt.wantExternalURLs[i] {
|
|
|
|
|
t.Errorf("extractCardImageKeys() externalURLs[%d] = %q, want %q", i, v, tt.wantExternalURLs[i])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|