picoclaw/pkg/channels/feishu/common_test.go

503 lines
13 KiB
Go
Raw Permalink Normal View History

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
fix(feishu): fix image download with API fallback and post image support (#2708) * fix(feishu): fix image download with API fallback and post image support - Add Image.Get API fallback when MessageResource.Get fails (different permission scope: im:resource vs im:message:readonly) - Extract and download images from post (rich text) messages - Extract images from interactive card messages - Deduplicate post image keys across locales - Add comprehensive tests for new helpers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(media): add image path tags alongside base64 for LLM file access Images are still base64-encoded into msg.Media for multimodal LLMs, but now also get [image:path] tags injected into message content so the LLM knows the local file path for save/forward operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(media): only auto-inject images for tool results, not user messages Channel-received images (role=user) now get path tags only, letting the LLM decide whether to view via load_image or just operate on the file. Tool result images (role=tool, e.g. load_image) are base64-encoded into a synthetic user message appended after the tool message, since many LLM APIs don't support image_url in tool messages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(media): preserve tool-message ordering for multi-tool-call scenarios Move synthetic user message (carrying base64 tool images) to after the entire contiguous tool-message block instead of immediately after each tool message. This preserves the assistant→tool→tool ordering required by OpenAI-compatible APIs. Also fix load_image to use generic [image: photo] placeholder so injectPathTags can properly replace it with the actual path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): update load_image test for [image: photo] placeholder The test was checking ForLLM for the media:// ref, but load_image now emits the generic [image: photo] placeholder instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(media): match all channel image placeholders in injectPathTags Different channels emit different placeholder formats — Telegram/Feishu use [image: photo], WeCom/WeChat/Line use bare [image], QQ/Discord use [image: <filename>]. The previous string-match code only handled [image: photo], so for the other channels the path tag was appended as a duplicate, producing content like "[image] [image:/path]". Switch to per-type regex that matches all generic placeholder shapes while leaving path tags ([image:/path]) untouched. Also fixes the same issue for [audio], [video], [file] tags. Added test coverage for the various placeholder shapes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(media): skip path tag append for JSON content (Feishu cards/posts) When content is structured JSON (interactive cards, post messages), injectPathTags now skips the fallback append — only placeholder replacement is attempted. This prevents corrupting JSON payloads like {"schema":"2.0",...} with appended [image:/path] tags. Adds looksLikeJSON() helper and three test cases covering JSON objects, arrays, and an end-to-end resolveMediaRefs scenario with Feishu card content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(media): prepend path tags for JSON content, narrow looksLikeJSON Two fixes from code review: 1. looksLikeJSON now only checks for '{' prefix (not '['), avoiding false positives on regular text like "[update] see attached". 2. For JSON content (Feishu cards/posts), path tags are prepended before the JSON instead of being silently dropped. This ensures the LLM can discover attached images via the path tag while the JSON payload stays valid for downstream parsing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 03:08:00 +00:00
func TestExtractPostImageKeys(t *testing.T) {
tests := []struct {
name string
content string
want []string
}{
{
name: "empty content",
content: "",
want: nil,
},
{
name: "invalid JSON",
content: "not json",
want: nil,
},
{
name: "post with no images",
content: `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"hello"}]]}}`,
want: nil,
},
{
name: "post with one image",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_v3_001"}]]}}`,
want: []string{"img_v3_001"},
},
{
name: "post with multiple images",
content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"see"},{"tag":"img","image_key":"img_001"}],[{"tag":"img","image_key":"img_002"}]]}}`,
want: []string{"img_001", "img_002"},
},
{
name: "post with text and image mixed in row",
content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"hi"},{"tag":"img","image_key":"img_mix"}]]}}`,
want: []string{"img_mix"},
},
{
name: "en_us locale",
content: `{"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`,
want: []string{"img_en"},
},
{
name: "multiple locales with distinct images",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_zh"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`,
want: []string{"img_zh", "img_en"},
},
{
name: "duplicate image_key across locales is deduplicated",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]}}`,
want: []string{"img_same"},
},
{
name: "image with empty image_key",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":""}]]}}`,
want: nil,
},
{
name: "flat format without locale wrapper",
content: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_flat","width":1826,"height":338}],[{"tag":"text","text":" check this image","style":[]}]]}`,
want: []string{"img_v3_flat"},
},
{
name: "flat format multiple images",
content: `{"title":"","content":[[{"tag":"img","image_key":"img_flat_1"}],[{"tag":"img","image_key":"img_flat_2"},{"tag":"text","text":"desc"}]]}`,
want: []string{"img_flat_1", "img_flat_2"},
},
{
name: "flat format no images",
content: `{"title":"Test","content":[[{"tag":"text","text":"just text"}]]}`,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPostImageKeys(tt.content)
if len(got) != len(tt.want) {
t.Errorf("extractPostImageKeys() = %v, want %v", got, tt.want)
return
}
// Use set comparison to avoid map iteration order dependency
gotSet := make(map[string]bool, len(got))
for _, v := range got {
gotSet[v] = true
}
for _, v := range tt.want {
if !gotSet[v] {
t.Errorf("extractPostImageKeys() missing expected key %q; got %v", v, got)
}
}
})
}
}
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])
}
}
})
}
}