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:
parent
9b109dc7a8
commit
0420d1dcb1
4 changed files with 257 additions and 24 deletions
|
|
@ -64,6 +64,62 @@ func extractJSONStringField(content, field string) string {
|
||||||
// Format: {"image_key": "img_xxx"}
|
// Format: {"image_key": "img_xxx"}
|
||||||
func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") }
|
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.
|
// extractFileKey extracts the file_key from a Feishu file/audio message content JSON.
|
||||||
// Format: {"file_key": "file_xxx", "file_name": "...", ...}
|
// Format: {"file_key": "file_xxx", "file_name": "...", ...}
|
||||||
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }
|
func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") }
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestExtractCardImageKeys(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -803,6 +803,14 @@ func (c *FeishuChannel) downloadInboundMedia(
|
||||||
refs = append(refs, ref)
|
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:
|
case larkim.MsgTypeInteractive:
|
||||||
// Extract and download images embedded in interactive cards
|
// Extract and download images embedded in interactive cards
|
||||||
feishuKeys, _ := extractCardImageKeys(rawContent)
|
feishuKeys, _ := extractCardImageKeys(rawContent)
|
||||||
|
|
@ -842,12 +850,41 @@ func (c *FeishuChannel) downloadInboundMedia(
|
||||||
// downloadResource downloads a message resource (image/file) from Feishu,
|
// downloadResource downloads a message resource (image/file) from Feishu,
|
||||||
// writes it to the project media directory, and stores the reference in MediaStore.
|
// 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.
|
// 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(
|
func (c *FeishuChannel) downloadResource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
messageID, fileKey, resourceType, fallbackExt string,
|
messageID, fileKey, resourceType, fallbackExt string,
|
||||||
store media.MediaStore,
|
store media.MediaStore,
|
||||||
scope string,
|
scope string,
|
||||||
) 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().
|
req := larkim.NewGetMessageResourceReqBuilder().
|
||||||
MessageId(messageID).
|
MessageId(messageID).
|
||||||
FileKey(fileKey).
|
FileKey(fileKey).
|
||||||
|
|
@ -855,41 +892,80 @@ func (c *FeishuChannel) downloadResource(
|
||||||
Build()
|
Build()
|
||||||
|
|
||||||
resp, err := c.client.Im.V1.MessageResource.Get(ctx, req)
|
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 {
|
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,
|
"message_id": messageID,
|
||||||
"file_key": fileKey,
|
"file_key": fileKey,
|
||||||
"error": err.Error(),
|
"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() {
|
if !resp.Success() {
|
||||||
c.invalidateTokenOnAuthError(resp.Code)
|
c.invalidateTokenOnAuthError(resp.Code)
|
||||||
logger.ErrorCF("feishu", "Resource download api error", map[string]any{
|
logger.ErrorCF("feishu", "Image.Get fallback api error", map[string]any{
|
||||||
"code": resp.Code,
|
"image_key": imageKey,
|
||||||
"msg": resp.Msg,
|
"code": resp.Code,
|
||||||
|
"msg": resp.Msg,
|
||||||
})
|
})
|
||||||
return ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.File == nil {
|
if resp.File == nil {
|
||||||
return ""
|
return nil, ""
|
||||||
}
|
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := resp.FileName
|
logger.DebugCF("feishu", "Image downloaded via Image.Get fallback", map[string]any{
|
||||||
if filename == "" {
|
"image_key": imageKey,
|
||||||
filename = fileKey
|
})
|
||||||
}
|
return resp.File, resp.FileName
|
||||||
// If filename still has no extension, append the fallback (like Telegram's ext parameter).
|
}
|
||||||
if filepath.Ext(filename) == "" && fallbackExt != "" {
|
|
||||||
filename += fallbackExt
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
mediaDir := media.TempDir()
|
||||||
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
|
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
|
||||||
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
|
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
|
||||||
|
|
@ -908,7 +984,7 @@ func (c *FeishuChannel) downloadResource(
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, copyErr := io.Copy(out, resp.File); copyErr != nil {
|
if _, copyErr := io.Copy(out, file); copyErr != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(localPath)
|
os.Remove(localPath)
|
||||||
logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{
|
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
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't append tags to JSON content (interactive cards) - would produce invalid JSON
|
// Don't append tags to JSON content - would produce invalid JSON
|
||||||
if messageType == larkim.MsgTypeInteractive {
|
if messageType == larkim.MsgTypeInteractive || messageType == larkim.MsgTypePost {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,13 @@ func TestAppendMediaTags(t *testing.T) {
|
||||||
mediaRefs: []string{"ref1"},
|
mediaRefs: []string{"ref1"},
|
||||||
want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
|
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 {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue