From 7d32d5af5ab1c161d320d866ae1b0c692e0b06ad Mon Sep 17 00:00:00 2001 From: shikihane Date: Tue, 3 Mar 2026 16:15:09 +0800 Subject: [PATCH] feat(feishu): support inbound image messages with MessageResource API Add image extraction from both pure image messages (MsgTypeImage) and rich text post messages (MsgTypePost) in the Feishu channel. Key changes: - Download user-sent images via MessageResource.Get API (not Image.Get, which only works for bot-uploaded images) - Parse post message format to extract embedded img tags - Use image_key as filename instead of hardcoded .jpg extension - Store downloaded images via MediaStore for media:// ref pipeline Note: This PR depends on the vision pipeline PR for resolving media:// refs to base64 data URLs before sending to the LLM. Co-Authored-By: Claude Opus 4.6 --- pkg/channels/feishu/feishu_64.go | 138 +++++++++++++++++++++++++- pkg/channels/feishu/feishu_64_test.go | 92 +++++++++++++++++ 2 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 pkg/channels/feishu/feishu_64_test.go diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 1db1bf669..a5cc6e16c 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -6,9 +6,13 @@ import ( "context" "encoding/json" "fmt" + "io" + "os" + "path/filepath" "sync" "time" + "github.com/google/uuid" lark "github.com/larksuite/oapi-sdk-go/v3" larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" @@ -19,6 +23,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -152,15 +157,66 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. } content := extractFeishuMessageContent(message) + + messageID := stringValue(message.MessageId) + scope := channels.BuildMediaScope("feishu", chatID, messageID) + + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "feishu", + }, scope) + if err == nil { + return ref + } + } + return localPath + } + + var mediaPaths []string + + msgType := stringValue(message.MessageType) + switch msgType { + case larkim.MsgTypeImage: + // Pure image message: {"image_key":"..."} + if imageKey := extractFeishuImageKey(stringValue(message.Content)); imageKey != "" { + localPath, err := c.downloadImage(ctx, messageID, imageKey) + if err != nil { + logger.ErrorCF("feishu", "Failed to download image", map[string]any{ + "image_key": imageKey, + "error": err.Error(), + }) + } else if localPath != "" { + mediaPaths = append(mediaPaths, storeMedia(localPath, imageKey)) + if content != "" { + content += "\n" + } + content += "[image: photo]" + } + } + case larkim.MsgTypePost: + // Rich text (post) message: {"title":"...","content":[[{"tag":"img","image_key":"..."},{"tag":"text","text":"..."}]]} + for _, imageKey := range extractFeishuPostImageKeys(stringValue(message.Content)) { + localPath, err := c.downloadImage(ctx, messageID, imageKey) + if err != nil { + logger.ErrorCF("feishu", "Failed to download post image", map[string]any{ + "image_key": imageKey, + "error": err.Error(), + }) + continue + } + if localPath != "" { + mediaPaths = append(mediaPaths, storeMedia(localPath, imageKey)) + } + } + } + if content == "" { content = "[empty message]" } metadata := map[string]string{} - messageID := "" - if mid := stringValue(message.MessageId); mid != "" { - messageID = mid - } if messageType := stringValue(message.MessageType); messageType != "" { metadata["message_type"] = messageType } @@ -201,7 +257,7 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. return nil } - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo) + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, senderInfo) return nil } @@ -239,3 +295,75 @@ func extractFeishuMessageContent(message *larkim.EventMessage) string { return *message.Content } + +func extractFeishuImageKey(content string) string { + var payload struct { + ImageKey string `json:"image_key"` + } + if err := json.Unmarshal([]byte(content), &payload); err != nil { + return "" + } + return payload.ImageKey +} + +// extractFeishuPostImageKeys extracts all image_key values from a post (rich text) message. +// Post format: {"title":"...","content":[[{"tag":"img","image_key":"..."},{"tag":"text","text":"..."}]]} +func extractFeishuPostImageKeys(content string) []string { + var payload struct { + Content [][]struct { + Tag string `json:"tag"` + ImageKey string `json:"image_key"` + } `json:"content"` + } + if err := json.Unmarshal([]byte(content), &payload); err != nil { + return nil + } + var keys []string + for _, line := range payload.Content { + for _, elem := range line { + if elem.Tag == "img" && elem.ImageKey != "" { + keys = append(keys, elem.ImageKey) + } + } + } + return keys +} + +func (c *FeishuChannel) downloadImage(ctx context.Context, messageID, imageKey string) (string, error) { + req := larkim.NewGetMessageResourceReqBuilder(). + MessageId(messageID). + FileKey(imageKey). + Type("image"). + Build() + resp, err := c.client.Im.V1.MessageResource.Get(ctx, req) + if err != nil { + return "", fmt.Errorf("feishu message resource get: %w", err) + } + if !resp.Success() { + return "", fmt.Errorf("feishu message resource get: code=%d msg=%s", resp.Code, resp.Msg) + } + + mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") + if err := os.MkdirAll(mediaDir, 0o700); err != nil { + return "", fmt.Errorf("create media dir: %w", err) + } + + localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_feishu_image") + out, err := os.Create(localPath) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.File); err != nil { + out.Close() + os.Remove(localPath) + return "", fmt.Errorf("write image: %w", err) + } + + logger.DebugCF("feishu", "Image downloaded", map[string]any{ + "image_key": imageKey, + "path": localPath, + }) + return localPath, nil +} diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go new file mode 100644 index 000000000..a1732feea --- /dev/null +++ b/pkg/channels/feishu/feishu_64_test.go @@ -0,0 +1,92 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + +package feishu + +import ( + "testing" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +func strPtr(s string) *string { return &s } + +func TestExtractFeishuImageKey(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "valid image key", + content: `{"image_key":"img_v2_abc123"}`, + want: "img_v2_abc123", + }, + { + name: "empty content", + content: "", + want: "", + }, + { + name: "invalid json", + content: "not json", + want: "", + }, + { + name: "missing image_key field", + content: `{"text":"hello"}`, + want: "", + }, + { + name: "empty image_key", + content: `{"image_key":""}`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractFeishuImageKey(tt.content) + if got != tt.want { + t.Errorf("extractFeishuImageKey(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} + +func TestExtractFeishuMessageContent_TextUnchanged(t *testing.T) { + msgType := larkim.MsgTypeText + content := `{"text":"hello world"}` + + msg := &larkim.EventMessage{ + MessageType: &msgType, + Content: &content, + } + + got := extractFeishuMessageContent(msg) + if got != "hello world" { + t.Errorf("extractFeishuMessageContent() = %q, want %q", got, "hello world") + } +} + +func TestExtractFeishuMessageContent_ImageReturnsRaw(t *testing.T) { + msgType := larkim.MsgTypeImage + content := `{"image_key":"img_v2_xxx"}` + + msg := &larkim.EventMessage{ + MessageType: &msgType, + Content: &content, + } + + // For non-text messages, extractFeishuMessageContent returns raw content + got := extractFeishuMessageContent(msg) + if got != content { + t.Errorf("extractFeishuMessageContent() = %q, want %q", got, content) + } +} + +func TestExtractFeishuMessageContent_NilMessage(t *testing.T) { + got := extractFeishuMessageContent(nil) + if got != "" { + t.Errorf("extractFeishuMessageContent(nil) = %q, want empty", got) + } +}