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 <noreply@anthropic.com>
This commit is contained in:
parent
de2ccb5da4
commit
7d32d5af5a
2 changed files with 225 additions and 5 deletions
|
|
@ -6,9 +6,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
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/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -152,15 +157,66 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
||||||
}
|
}
|
||||||
|
|
||||||
content := extractFeishuMessageContent(message)
|
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 == "" {
|
if content == "" {
|
||||||
content = "[empty message]"
|
content = "[empty message]"
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := map[string]string{}
|
metadata := map[string]string{}
|
||||||
messageID := ""
|
|
||||||
if mid := stringValue(message.MessageId); mid != "" {
|
|
||||||
messageID = mid
|
|
||||||
}
|
|
||||||
if messageType := stringValue(message.MessageType); messageType != "" {
|
if messageType := stringValue(message.MessageType); messageType != "" {
|
||||||
metadata["message_type"] = messageType
|
metadata["message_type"] = messageType
|
||||||
}
|
}
|
||||||
|
|
@ -201,7 +257,7 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
|
||||||
return nil
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,3 +295,75 @@ func extractFeishuMessageContent(message *larkim.EventMessage) string {
|
||||||
|
|
||||||
return *message.Content
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
92
pkg/channels/feishu/feishu_64_test.go
Normal file
92
pkg/channels/feishu/feishu_64_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue