2026-03-03 08:43:04 +00:00
|
|
|
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
|
|
|
|
|
|
|
|
|
package feishu
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestExtractContent(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
messageType string
|
|
|
|
|
rawContent string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "text message",
|
|
|
|
|
messageType: "text",
|
|
|
|
|
rawContent: `{"text": "hello world"}`,
|
|
|
|
|
want: "hello world",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "text message invalid JSON",
|
|
|
|
|
messageType: "text",
|
|
|
|
|
rawContent: `not json`,
|
|
|
|
|
want: "not json",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "post message returns raw JSON",
|
|
|
|
|
messageType: "post",
|
|
|
|
|
rawContent: `{"title": "test post"}`,
|
|
|
|
|
want: `{"title": "test post"}`,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "image message returns empty",
|
|
|
|
|
messageType: "image",
|
|
|
|
|
rawContent: `{"image_key": "img_xxx"}`,
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "file message with filename",
|
|
|
|
|
messageType: "file",
|
|
|
|
|
rawContent: `{"file_key": "file_xxx", "file_name": "report.pdf"}`,
|
|
|
|
|
want: "report.pdf",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "file message without filename",
|
|
|
|
|
messageType: "file",
|
|
|
|
|
rawContent: `{"file_key": "file_xxx"}`,
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "audio message with filename",
|
|
|
|
|
messageType: "audio",
|
|
|
|
|
rawContent: `{"file_key": "file_xxx", "file_name": "recording.ogg"}`,
|
|
|
|
|
want: "recording.ogg",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "media message with filename",
|
|
|
|
|
messageType: "media",
|
|
|
|
|
rawContent: `{"file_key": "file_xxx", "file_name": "video.mp4"}`,
|
|
|
|
|
want: "video.mp4",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "unknown message type returns raw",
|
|
|
|
|
messageType: "sticker",
|
|
|
|
|
rawContent: `{"sticker_id": "sticker_xxx"}`,
|
|
|
|
|
want: `{"sticker_id": "sticker_xxx"}`,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty raw content",
|
|
|
|
|
messageType: "text",
|
|
|
|
|
rawContent: "",
|
|
|
|
|
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
|
|
|
{
|
|
|
|
|
name: "interactive card returns raw JSON",
|
|
|
|
|
messageType: "interactive",
|
|
|
|
|
rawContent: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`,
|
|
|
|
|
want: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "interactive card with complex structure returns raw JSON",
|
|
|
|
|
messageType: "interactive",
|
|
|
|
|
rawContent: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`,
|
|
|
|
|
want: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "interactive card invalid JSON returns as-is",
|
|
|
|
|
messageType: "interactive",
|
|
|
|
|
rawContent: `not valid json`,
|
|
|
|
|
want: `not valid json`,
|
|
|
|
|
},
|
2026-03-03 08:43:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got := extractContent(tt.messageType, tt.rawContent)
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("extractContent(%q, %q) = %q, want %q", tt.messageType, tt.rawContent, got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAppendMediaTags(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
content string
|
|
|
|
|
messageType string
|
|
|
|
|
mediaRefs []string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "no refs returns content unchanged",
|
|
|
|
|
content: "hello",
|
|
|
|
|
messageType: "image",
|
|
|
|
|
mediaRefs: nil,
|
|
|
|
|
want: "hello",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "empty refs returns content unchanged",
|
|
|
|
|
content: "hello",
|
|
|
|
|
messageType: "image",
|
|
|
|
|
mediaRefs: []string{},
|
|
|
|
|
want: "hello",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "image with content",
|
|
|
|
|
content: "check this",
|
|
|
|
|
messageType: "image",
|
|
|
|
|
mediaRefs: []string{"ref1"},
|
|
|
|
|
want: "check this [image: photo]",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "image empty content",
|
|
|
|
|
content: "",
|
|
|
|
|
messageType: "image",
|
|
|
|
|
mediaRefs: []string{"ref1"},
|
|
|
|
|
want: "[image: photo]",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "audio",
|
|
|
|
|
content: "listen",
|
|
|
|
|
messageType: "audio",
|
|
|
|
|
mediaRefs: []string{"ref1"},
|
|
|
|
|
want: "listen [audio]",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "media/video",
|
|
|
|
|
content: "watch",
|
|
|
|
|
messageType: "media",
|
|
|
|
|
mediaRefs: []string{"ref1"},
|
|
|
|
|
want: "watch [video]",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "file",
|
|
|
|
|
content: "report.pdf",
|
|
|
|
|
messageType: "file",
|
|
|
|
|
mediaRefs: []string{"ref1"},
|
|
|
|
|
want: "report.pdf [file]",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "unknown type",
|
|
|
|
|
content: "something",
|
|
|
|
|
messageType: "sticker",
|
|
|
|
|
mediaRefs: []string{"ref1"},
|
|
|
|
|
want: "something [attachment]",
|
|
|
|
|
},
|
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
|
|
|
{
|
|
|
|
|
name: "interactive card with images returns content unchanged",
|
|
|
|
|
content: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
|
|
|
|
|
messageType: "interactive",
|
|
|
|
|
mediaRefs: []string{"ref1"},
|
|
|
|
|
want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
|
|
|
|
|
},
|
2026-03-03 08:43:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got := appendMediaTags(tt.content, tt.messageType, tt.mediaRefs)
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf(
|
|
|
|
|
"appendMediaTags(%q, %q, %v) = %q, want %q",
|
|
|
|
|
tt.content,
|
|
|
|
|
tt.messageType,
|
|
|
|
|
tt.mediaRefs,
|
|
|
|
|
got,
|
|
|
|
|
tt.want,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExtractFeishuSenderID(t *testing.T) {
|
|
|
|
|
strPtr := func(s string) *string { return &s }
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
sender *larkim.EventSender
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "nil sender",
|
|
|
|
|
sender: nil,
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "nil sender ID",
|
|
|
|
|
sender: &larkim.EventSender{SenderId: nil},
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "userId preferred",
|
|
|
|
|
sender: &larkim.EventSender{
|
|
|
|
|
SenderId: &larkim.UserId{
|
|
|
|
|
UserId: strPtr("u_abc123"),
|
|
|
|
|
OpenId: strPtr("ou_def456"),
|
|
|
|
|
UnionId: strPtr("on_ghi789"),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
want: "u_abc123",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "openId fallback",
|
|
|
|
|
sender: &larkim.EventSender{
|
|
|
|
|
SenderId: &larkim.UserId{
|
|
|
|
|
UserId: strPtr(""),
|
|
|
|
|
OpenId: strPtr("ou_def456"),
|
|
|
|
|
UnionId: strPtr("on_ghi789"),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
want: "ou_def456",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "unionId fallback",
|
|
|
|
|
sender: &larkim.EventSender{
|
|
|
|
|
SenderId: &larkim.UserId{
|
|
|
|
|
UserId: strPtr(""),
|
|
|
|
|
OpenId: strPtr(""),
|
|
|
|
|
UnionId: strPtr("on_ghi789"),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
want: "on_ghi789",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "all empty strings",
|
|
|
|
|
sender: &larkim.EventSender{
|
|
|
|
|
SenderId: &larkim.UserId{
|
|
|
|
|
UserId: strPtr(""),
|
|
|
|
|
OpenId: strPtr(""),
|
|
|
|
|
UnionId: strPtr(""),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
want: "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "nil userId pointer falls through",
|
|
|
|
|
sender: &larkim.EventSender{
|
|
|
|
|
SenderId: &larkim.UserId{
|
|
|
|
|
UserId: nil,
|
|
|
|
|
OpenId: strPtr("ou_def456"),
|
|
|
|
|
UnionId: nil,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
want: "ou_def456",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got := extractFeishuSenderID(tt.sender)
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
t.Errorf("extractFeishuSenderID() = %q, want %q", got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|