diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md index 310827723..01296253d 100644 --- a/docs/channels/feishu/README.zh.md +++ b/docs/channels/feishu/README.zh.md @@ -35,3 +35,13 @@ 3. 配置事件订阅和Webhook URL 4. 设置加密(可选,生产环境建议启用) 5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中 + +## 发送本地图片 + +飞书频道现已支持通过 `im/v1/image/create` 上传本地图片并发送图片消息。 + +- 如果发送内容是本地图片路径(如 `/tmp/a.png` 或 `file:///tmp/a.png`),会自动上传并发送图片。 +- 如果发送内容包含 Markdown 图片语法(如 `![img](/tmp/a.png)`),会提取并发送图片。 +- 同一条消息里如果同时包含文本和 Markdown 图片,文本会先发送,再发送图片。 + +支持格式:`jpg/jpeg/png/webp/gif/tiff/bmp/ico`。 diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go index 42e74980f..2e53dc2de 100644 --- a/pkg/channels/feishu_64.go +++ b/pkg/channels/feishu_64.go @@ -6,9 +6,15 @@ import ( "context" "encoding/json" "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" "sync" "time" + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" 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" @@ -20,6 +26,8 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +var feishuMarkdownImageRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`) + type FeishuChannel struct { *BaseChannel config config.FeishuConfig @@ -30,6 +38,16 @@ type FeishuChannel struct { cancel context.CancelFunc } +type feishuEventEnvelope struct { + Header *struct { + EventType string `json:"event_type"` + } `json:"header"` + Event *struct { + Sender *larkim.EventSender `json:"sender"` + Message *larkim.EventMessage `json:"message"` + } `json:"event"` +} + func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom) @@ -46,7 +64,8 @@ func (c *FeishuChannel) Start(ctx context.Context) error { } dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey). - OnP2MessageReceiveV1(c.handleMessageReceive) + OnCustomizedEvent("im.message.receive_v1", c.handleMessageReceiveRaw). + OnCustomizedEvent("im.message.receive_v2", c.handleMessageReceiveRaw) runCtx, cancel := context.WithCancel(ctx) @@ -97,17 +116,71 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("chat ID is empty") } - payload, err := json.Marshal(map[string]string{"text": msg.Content}) - if err != nil { - return fmt.Errorf("failed to marshal feishu content: %w", err) + textContent, imagePaths := splitFeishuOutboundContent(msg.Content) + + if textContent == "" && len(imagePaths) == 0 { + return nil } + if textContent != "" { + if err := c.sendFeishuTextMessage(ctx, msg.ChatID, textContent); err != nil { + return err + } + } + + for _, imagePath := range imagePaths { + imageKey, err := c.uploadFeishuImage(ctx, imagePath) + if err != nil { + return fmt.Errorf("failed to upload feishu image %q: %w", imagePath, err) + } + + if err := c.sendFeishuImageMessage(ctx, msg.ChatID, imageKey); err != nil { + return fmt.Errorf("failed to send feishu image %q: %w", imagePath, err) + } + + logger.DebugCF("feishu", "Feishu image sent", map[string]any{ + "chat_id": msg.ChatID, + "image_path": imagePath, + }) + } + + return nil +} + +func (c *FeishuChannel) sendFeishuTextMessage(ctx context.Context, chatID, content string) error { + payload, err := json.Marshal(map[string]string{"text": content}) + if err != nil { + return fmt.Errorf("failed to marshal feishu text content: %w", err) + } + + if err := c.sendFeishuMessage(ctx, chatID, larkim.MsgTypeText, string(payload)); err != nil { + return err + } + + logger.DebugCF("feishu", "Feishu text message sent", map[string]any{ + "chat_id": chatID, + }) + + return nil +} + +func (c *FeishuChannel) sendFeishuImageMessage(ctx context.Context, chatID, imageKey string) error { + payload, err := json.Marshal(map[string]string{"image_key": imageKey}) + if err != nil { + return fmt.Errorf("failed to marshal feishu image content: %w", err) + } + + return c.sendFeishuMessage(ctx, chatID, larkim.MsgTypeImage, string(payload)) +} + +func (c *FeishuChannel) sendFeishuMessage(ctx context.Context, chatID, msgType, content string) error { + req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). - ReceiveId(msg.ChatID). - MsgType(larkim.MsgTypeText). - Content(string(payload)). + ReceiveId(chatID). + MsgType(msgType). + Content(content). Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())). Build()). Build() @@ -121,20 +194,66 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg) } - logger.DebugCF("feishu", "Feishu message sent", map[string]any{ - "chat_id": msg.ChatID, - }) - return nil } -func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error { - if event == nil || event.Event == nil || event.Event.Message == nil { +func (c *FeishuChannel) uploadFeishuImage(ctx context.Context, imagePath string) (string, error) { + body, err := larkim.NewCreateImagePathReqBodyBuilder(). + ImageType("message"). + ImagePath(imagePath). + Build() + if err != nil { + return "", fmt.Errorf("failed to read image file: %w", err) + } + + req := larkim.NewCreateImageReqBuilder(). + Body(body). + Build() + + resp, err := c.client.Im.V1.Image.Create(ctx, req) + if err != nil { + return "", fmt.Errorf("failed to upload image: %w", err) + } + + if !resp.Success() { + return "", fmt.Errorf("feishu image api error: code=%d msg=%s", resp.Code, resp.Msg) + } + + if resp.Data == nil || resp.Data.ImageKey == nil || *resp.Data.ImageKey == "" { + return "", fmt.Errorf("feishu image upload succeeded but image_key is empty") + } + + return *resp.Data.ImageKey, nil +} + +func (c *FeishuChannel) handleMessageReceiveRaw(_ context.Context, event *larkevent.EventReq) error { + if event == nil || len(event.Body) == 0 { + logger.WarnC("feishu", "Received empty custom Feishu event payload") return nil } - message := event.Event.Message - sender := event.Event.Sender + var envelope feishuEventEnvelope + if err := json.Unmarshal(event.Body, &envelope); err != nil { + logger.ErrorCF("feishu", "Failed to parse Feishu message event payload", map[string]any{ + "error": err.Error(), + "payload": utils.Truncate(string(event.Body), 300), + }) + return err + } + + if envelope.Event == nil || envelope.Event.Message == nil { + eventType := "" + if envelope.Header != nil { + eventType = envelope.Header.EventType + } + logger.DebugCF("feishu", "Ignored Feishu event without message body", map[string]any{ + "event_type": eventType, + }) + return nil + } + + message := envelope.Event.Message + sender := envelope.Event.Sender chatID := stringValue(message.ChatId) if chatID == "" { @@ -225,3 +344,112 @@ func stringValue(v *string) string { } return *v } + +func splitFeishuOutboundContent(content string) (string, []string) { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return "", nil + } + + imagePaths := make([]string, 0, 2) + + cleaned := feishuMarkdownImageRe.ReplaceAllStringFunc(trimmed, func(segment string) string { + matches := feishuMarkdownImageRe.FindStringSubmatch(segment) + if len(matches) < 2 { + return segment + } + + if imagePath, ok := normalizeFeishuLocalImagePath(matches[1]); ok { + imagePaths = append(imagePaths, imagePath) + return "" + } + + return segment + }) + + cleaned = strings.TrimSpace(cleaned) + + if len(imagePaths) == 0 { + if imagePath, ok := normalizeFeishuLocalImagePath(cleaned); ok { + return "", []string{imagePath} + } + } + + if len(imagePaths) == 0 { + return cleaned, nil + } + + return cleaned, dedupeStrings(imagePaths) +} + +func normalizeFeishuLocalImagePath(candidate string) (string, bool) { + candidate = strings.TrimSpace(candidate) + candidate = strings.Trim(candidate, "\"'") + candidate = strings.Trim(candidate, "<>") + if candidate == "" { + return "", false + } + + if strings.HasPrefix(strings.ToLower(candidate), "file://") { + parsed, err := url.Parse(candidate) + if err != nil || parsed.Scheme != "file" { + return "", false + } + if parsed.Host != "" && parsed.Host != "localhost" { + return "", false + } + + path, err := url.PathUnescape(parsed.Path) + if err != nil { + return "", false + } + candidate = path + } + + if candidate == "" { + return "", false + } + + absPath, err := filepath.Abs(candidate) + if err != nil { + return "", false + } + + info, err := os.Stat(absPath) + if err != nil || info.IsDir() { + return "", false + } + + if !isFeishuSupportedImageFile(absPath) { + return "", false + } + + return absPath, true +} + +func isFeishuSupportedImageFile(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff", ".tif", ".bmp", ".ico": + return true + default: + return false + } +} + +func dedupeStrings(values []string) []string { + if len(values) <= 1 { + return values + } + + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + + return result +} diff --git a/pkg/channels/feishu_64_test.go b/pkg/channels/feishu_64_test.go new file mode 100644 index 000000000..093c7bc28 --- /dev/null +++ b/pkg/channels/feishu_64_test.go @@ -0,0 +1,68 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + +package channels + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSplitFeishuOutboundContent_MarkdownImageOnly(t *testing.T) { + imagePath := createFeishuTempImageFile(t, "test_image.png") + + text, images := splitFeishuOutboundContent("![](" + imagePath + ")") + if text != "" { + t.Fatalf("expected empty text, got %q", text) + } + if len(images) != 1 || images[0] != imagePath { + t.Fatalf("expected one image path %q, got %#v", imagePath, images) + } +} + +func TestSplitFeishuOutboundContent_TextAndMarkdownImage(t *testing.T) { + imagePath := createFeishuTempImageFile(t, "test_image.png") + + text, images := splitFeishuOutboundContent("请查看图片\n![img](" + imagePath + ")") + if text != "请查看图片" { + t.Fatalf("expected text to be cleaned, got %q", text) + } + if len(images) != 1 || images[0] != imagePath { + t.Fatalf("expected one image path %q, got %#v", imagePath, images) + } +} + +func TestSplitFeishuOutboundContent_FileURI(t *testing.T) { + imagePath := createFeishuTempImageFile(t, "test_image.jpeg") + + text, images := splitFeishuOutboundContent("file://" + imagePath) + if text != "" { + t.Fatalf("expected empty text, got %q", text) + } + if len(images) != 1 || images[0] != imagePath { + t.Fatalf("expected one image path %q, got %#v", imagePath, images) + } +} + +func TestSplitFeishuOutboundContent_UnsupportedFileAsText(t *testing.T) { + nonImagePath := createFeishuTempImageFile(t, "notes.txt") + + text, images := splitFeishuOutboundContent(nonImagePath) + if text != nonImagePath { + t.Fatalf("expected text to keep original non-image path, got %q", text) + } + if len(images) != 0 { + t.Fatalf("expected no images, got %#v", images) + } +} + +func createFeishuTempImageFile(t *testing.T, name string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("test"), 0o600); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + return path +}