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>
This commit is contained in:
Guoguo 2026-04-28 05:46:15 -07:00
parent 9b109dc7a8
commit 0420d1dcb1
4 changed files with 257 additions and 24 deletions

View file

@ -64,6 +64,62 @@ func extractJSONStringField(content, field string) string {
// Format: {"image_key": "img_xxx"}
func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
// extractPostImageKeys extracts all image_key values from a Feishu post (rich text)
// message. Post messages have nested arrays of elements where images appear as
// {"tag":"img","image_key":"img_xxx"}.
func extractPostImageKeys(rawContent string) []string {
if rawContent == "" {
return nil
}
var post map[string]json.RawMessage
if err := json.Unmarshal([]byte(rawContent), &post); err != nil {
return nil
}
var keys []string
seen := make(map[string]struct{})
collectFromRows := func(contentRaw json.RawMessage) {
var rows [][]map[string]any
if err := json.Unmarshal(contentRaw, &rows); err != nil {
return
}
for _, row := range rows {
for _, elem := range row {
if tag, _ := elem["tag"].(string); tag == "img" {
if ik, _ := elem["image_key"].(string); ik != "" {
if _, dup := seen[ik]; !dup {
seen[ik] = struct{}{}
keys = append(keys, ik)
}
}
}
}
}
}
// Flat format: {"title":"...", "content":[[...]]}
if contentRaw, ok := post["content"]; ok {
collectFromRows(contentRaw)
}
// Localized format: {"zh_cn": {"title":"...", "content":[[...]]}, ...}
for _, raw := range post {
var locale map[string]json.RawMessage
if err := json.Unmarshal(raw, &locale); err != nil {
continue
}
contentRaw, ok := locale["content"]
if !ok {
continue
}
collectFromRows(contentRaw)
}
return keys
}
// extractFileKey extracts the file_key from a Feishu file/audio message content JSON.
// Format: {"file_key": "file_xxx", "file_name": "...", ...}
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }

View file

@ -291,6 +291,100 @@ func TestStripMentionPlaceholders(t *testing.T) {
}
}
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)
}
}
})
}
}
func TestExtractCardImageKeys(t *testing.T) {
tests := []struct {
name string

View file

@ -803,6 +803,14 @@ func (c *FeishuChannel) downloadInboundMedia(
refs = append(refs, ref)
}
case larkim.MsgTypePost:
for _, imageKey := range extractPostImageKeys(rawContent) {
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
if ref != "" {
refs = append(refs, ref)
}
}
case larkim.MsgTypeInteractive:
// Extract and download images embedded in interactive cards
feishuKeys, _ := extractCardImageKeys(rawContent)
@ -842,12 +850,41 @@ func (c *FeishuChannel) downloadInboundMedia(
// downloadResource downloads a message resource (image/file) from Feishu,
// writes it to the project media directory, and stores the reference in MediaStore.
// fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension.
//
// For image resources, if the primary MessageResource.Get API fails (which
// requires im:message or im:message:readonly scope), a fallback to the
// Image.Get API (which requires im:resource scope) is attempted. This ensures
// image downloads succeed regardless of which permission the user has granted.
func (c *FeishuChannel) downloadResource(
ctx context.Context,
messageID, fileKey, resourceType, fallbackExt string,
store media.MediaStore,
scope string,
) string {
file, filename := c.fetchResourceData(ctx, messageID, fileKey, resourceType)
if file == nil {
return ""
}
if closer, ok := file.(io.Closer); ok {
defer closer.Close()
}
if filename == "" {
filename = fileKey
}
if filepath.Ext(filename) == "" && fallbackExt != "" {
filename += fallbackExt
}
return c.storeResourceFile(ctx, messageID, fileKey, filename, file, store, scope)
}
// fetchResourceData tries to download a resource from Feishu, first via
// MessageResource.Get, then falling back to Image.Get for image resources.
func (c *FeishuChannel) fetchResourceData(
ctx context.Context,
messageID, fileKey, resourceType string,
) (io.Reader, string) {
req := larkim.NewGetMessageResourceReqBuilder().
MessageId(messageID).
FileKey(fileKey).
@ -855,41 +892,80 @@ func (c *FeishuChannel) downloadResource(
Build()
resp, err := c.client.Im.V1.MessageResource.Get(ctx, req)
if err == nil && resp.Success() && resp.File != nil {
return resp.File, resp.FileName
}
if err != nil {
logger.ErrorCF("feishu", "Failed to download resource", map[string]any{
logger.WarnCF("feishu", "MessageResource.Get failed", map[string]any{
"message_id": messageID,
"file_key": fileKey,
"error": err.Error(),
})
return ""
} else if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
logger.WarnCF("feishu", "MessageResource.Get api error", map[string]any{
"message_id": messageID,
"file_key": fileKey,
"code": resp.Code,
"msg": resp.Msg,
})
} else {
logger.WarnCF("feishu", "MessageResource.Get returned empty file body", map[string]any{
"message_id": messageID,
"file_key": fileKey,
})
}
if resourceType != "image" {
return nil, ""
}
return c.fetchImageDirect(ctx, fileKey)
}
// fetchImageDirect downloads an image using the Image.Get API
// (/open-apis/im/v1/images/:image_key), which requires the im:resource scope.
func (c *FeishuChannel) fetchImageDirect(ctx context.Context, imageKey string) (io.Reader, string) {
req := larkim.NewGetImageReqBuilder().
ImageKey(imageKey).
Build()
resp, err := c.client.Im.V1.Image.Get(ctx, req)
if err != nil {
logger.ErrorCF("feishu", "Image.Get fallback failed", map[string]any{
"image_key": imageKey,
"error": err.Error(),
})
return nil, ""
}
if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
logger.ErrorCF("feishu", "Resource download api error", map[string]any{
"code": resp.Code,
"msg": resp.Msg,
logger.ErrorCF("feishu", "Image.Get fallback api error", map[string]any{
"image_key": imageKey,
"code": resp.Code,
"msg": resp.Msg,
})
return ""
return nil, ""
}
if resp.File == nil {
return ""
}
// Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body).
if closer, ok := resp.File.(io.Closer); ok {
defer closer.Close()
return nil, ""
}
filename := resp.FileName
if filename == "" {
filename = fileKey
}
// If filename still has no extension, append the fallback (like Telegram's ext parameter).
if filepath.Ext(filename) == "" && fallbackExt != "" {
filename += fallbackExt
}
logger.DebugCF("feishu", "Image downloaded via Image.Get fallback", map[string]any{
"image_key": imageKey,
})
return resp.File, resp.FileName
}
// Write to the shared picoclaw_media directory using a unique name to avoid collisions.
// storeResourceFile writes downloaded resource data to disk and registers it in the MediaStore.
func (c *FeishuChannel) storeResourceFile(
ctx context.Context,
messageID, fileKey, filename string,
file io.Reader,
store media.MediaStore,
scope string,
) string {
mediaDir := media.TempDir()
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
@ -908,7 +984,7 @@ func (c *FeishuChannel) downloadResource(
return ""
}
if _, copyErr := io.Copy(out, resp.File); copyErr != nil {
if _, copyErr := io.Copy(out, file); copyErr != nil {
out.Close()
os.Remove(localPath)
logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{
@ -943,8 +1019,8 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string {
return content
}
// Don't append tags to JSON content (interactive cards) - would produce invalid JSON
if messageType == larkim.MsgTypeInteractive {
// Don't append tags to JSON content - would produce invalid JSON
if messageType == larkim.MsgTypeInteractive || messageType == larkim.MsgTypePost {
return content
}

View file

@ -180,6 +180,13 @@ func TestAppendMediaTags(t *testing.T) {
mediaRefs: []string{"ref1"},
want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
},
{
name: "post message with images returns content unchanged",
content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`,
messageType: "post",
mediaRefs: []string{"ref1"},
want: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`,
},
}
for _, tt := range tests {