From 18050872fc0e7bc10ff64adb55bfb604cac66954 Mon Sep 17 00:00:00 2001 From: lxowalle Date: Fri, 15 May 2026 11:42:14 +0800 Subject: [PATCH] fix(pico): preserve image media across pico attachments and client --- pkg/channels/pico/client.go | 13 +++++-- pkg/channels/pico/client_test.go | 58 ++++++++++++++++++++++++++++++++ pkg/channels/pico/pico.go | 57 +++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go index 009900e01..cfdaf7aa8 100644 --- a/pkg/channels/pico/client.go +++ b/pkg/channels/pico/client.go @@ -235,6 +235,8 @@ func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) { case TypeMessageCreate: // Server sent us a message — treat as inbound c.handleServerMessage(pc, msg) + case TypeMediaCreate: + c.handleServerMessage(pc, msg) default: logger.DebugCF("pico_client", "Ignoring message type", map[string]any{ "type": msg.Type, @@ -248,7 +250,14 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { } content, _ := msg.Payload[PayloadKeyContent].(string) - if strings.TrimSpace(content) == "" { + media, err := parseInlineImageMedia(msg.Payload) + if err != nil { + logger.WarnCF("pico_client", "Ignoring invalid media payload", map[string]any{ + "error": err.Error(), + }) + return + } + if strings.TrimSpace(content) == "" && len(media) == 0 { return } @@ -281,7 +290,7 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { }, } - c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender) + c.HandleInboundContext(c.ctx, chatID, content, media, inboundCtx, sender) } // Send sends a message to the remote server. diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index 2b167e457..2f5e8d27c 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -285,6 +285,24 @@ func TestParseInlineImageMedia_Valid(t *testing.T) { } } +func TestParseInlineImageMedia_Attachments(t *testing.T) { + imageURL := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + media, err := parseInlineImageMedia(map[string]any{ + "attachments": []any{ + map[string]any{ + "type": "image", + "url": imageURL, + }, + }, + }) + if err != nil { + t.Fatalf("parseInlineImageMedia() error = %v", err) + } + if len(media) != 1 || media[0] != imageURL { + t.Fatalf("media = %#v, want attachment image payload", media) + } +} + func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) { mb := bus.NewMessageBus() bc := &config.Channel{Type: "pico", Enabled: true} @@ -326,6 +344,46 @@ func TestPicoChannel_HandleMessageSend_AllowsMediaOnly(t *testing.T) { } } +func TestPicoClientChannel_HandleServerMessage_ForwardsMedia(t *testing.T) { + mb := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: "ws://localhost:8080/ws", + }, mb) + if err != nil { + t.Fatalf("NewPicoClientChannel() error = %v", err) + } + + ch.ctx = context.Background() + pc := &picoConn{sessionID: "sess-media"} + imageURL := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + + ch.handleServerMessage(pc, PicoMessage{ + Type: TypeMessageCreate, + Payload: map[string]any{ + PayloadKeyContent: "describe this", + "attachments": []any{ + map[string]any{ + "type": "image", + "url": imageURL, + }, + }, + }, + }) + + select { + case msg := <-mb.InboundChan(): + if msg.Content != "describe this" { + t.Fatalf("msg.Content = %q, want describe this", msg.Content) + } + if len(msg.Media) != 1 || msg.Media[0] != imageURL { + t.Fatalf("msg.Media = %#v, want forwarded image payload", msg.Media) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for forwarded media message") + } +} + func TestIsThoughtPayload(t *testing.T) { tests := []struct { name string diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index d1de8f4d5..58f917124 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -990,11 +990,24 @@ func parseInlineImageMedia(payload map[string]any) ([]string, error) { return nil, nil } - raw, ok := payload["media"] - if !ok || raw == nil { - return nil, nil + media, err := parseInlineImageValues(payload["media"]) + if err != nil { + return nil, err } + attachments, err := parseInlineImageAttachments(payload["attachments"]) + if err != nil { + return nil, err + } + media = append(media, attachments...) + + return media, nil +} + +func parseInlineImageValues(raw any) ([]string, error) { + if raw == nil { + return nil, nil + } switch values := raw.(type) { case []any: media := make([]string, 0, len(values)) @@ -1030,6 +1043,44 @@ func parseInlineImageMedia(payload map[string]any) ([]string, error) { } } +func parseInlineImageAttachments(raw any) ([]string, error) { + if raw == nil { + return nil, nil + } + + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("attachments must be an array") + } + + media := make([]string, 0, len(values)) + for i, item := range values { + attachment, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("attachments[%d]: attachment must be an object", i) + } + + attachmentType, _ := attachment["type"].(string) + attachmentType = strings.ToLower(strings.TrimSpace(attachmentType)) + if attachmentType != "" && attachmentType != "image" { + continue + } + + value, err := inlineImageValue(attachment) + if err != nil { + if attachmentType == "image" { + return nil, fmt.Errorf("attachments[%d]: %w", i, err) + } + continue + } + if err := validateInlineImageDataURL(value); err != nil { + return nil, fmt.Errorf("attachments[%d]: %w", i, err) + } + media = append(media, value) + } + return media, nil +} + func inlineImageValue(item any) (string, error) { switch value := item.(type) { case string: