From 76a3bfbedbb3a877e759c073cc237e44dd203d22 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 31 May 2026 21:03:24 +0100 Subject: [PATCH] feat(inbound): emit media in notification instead of appending footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel plugins now have a structured way to propagate attachments — Plexum's host turns each MediaItem into a canonical.ImageBlock content on the user message. This replaces the F-6 workaround that crammed local file paths into a markdown footer at the end of the message text. internal/inbound/inbound.go: - Notifier signature gains `media []MediaItem`; MediaItem mirrors the host's channel.MediaRef (Path/MediaType/Name) - dispatch downloads attachments (unchanged) then forwards results as MediaItem to Notify — no more footer appending - dispatch now also receives guildEndpoint so it can resolve Fabric's RELATIVE attachment URLs (`/api/files/`) against the guild base. Previously the downloader received the relative path verbatim and failed every fetch silently. cmd/plexum-fabric-channel-plugin/main.go: - notifier closure pushes media[] in the EmitNotification payload Live verified: alice uploads blue 32x32 PNG → Fabric guild → plugin downloads to /tmp/plexum-fabric//blue32.png → emits inbound with media → host routes to kimi agent → Kimi: "Blue". (MiniMax M2.7 is text-only per openclaw model definitions, so the same flow against MiniMax returns "I don't see an image" — that's a model capability limit, not a plugin issue.) Co-Authored-By: Claude Opus 4.7 --- cmd/plexum-fabric-channel-plugin/main.go | 18 ++++++++-- internal/inbound/inbound.go | 43 ++++++++++++++++++------ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/cmd/plexum-fabric-channel-plugin/main.go b/cmd/plexum-fabric-channel-plugin/main.go index 594c7d7..401a63c 100644 --- a/cmd/plexum-fabric-channel-plugin/main.go +++ b/cmd/plexum-fabric-channel-plugin/main.go @@ -201,12 +201,24 @@ func (p *fabricPlugin) Init(ctx context.Context, host plugin.HostAPI) error { ctxBg, cancel := context.WithCancel(context.Background()) p.inboundCancel = cancel p.inboundDone = make(chan struct{}) - notifier := func(channelName, message, sessionID string) { - p.host.EmitNotification("notifications/plexum/channel/inbound", map[string]any{ + notifier := func(channelName, message, sessionID string, media []inbound.MediaItem) { + payload := map[string]any{ "channel_name": channelName, "message": message, "session_id": sessionID, - }) + } + if len(media) > 0 { + mediaWire := make([]map[string]any, 0, len(media)) + for _, m := range media { + mediaWire = append(mediaWire, map[string]any{ + "path": m.Path, + "media_type": m.MediaType, + "name": m.Name, + }) + } + payload["media"] = mediaWire + } + p.host.EmitNotification("notifications/plexum/channel/inbound", payload) } // slog wrapping plugin.HostAPI.Log isn't worth the indirection // here; use a discard-style adapter that pipes WARN/INFO to diff --git a/internal/inbound/inbound.go b/internal/inbound/inbound.go index 1b0c789..875ec30 100644 --- a/internal/inbound/inbound.go +++ b/internal/inbound/inbound.go @@ -48,8 +48,19 @@ const ( ) // Notifier pushes one inbound message to the Plexum host. The plugin -// main wires this to HostAPI.EmitNotification. -type Notifier func(channelName, message, sessionID string) +// main wires this to HostAPI.EmitNotification. media items are +// passed through as `media` field in the channel-inbound notification +// (Plexum's channel manager turns them into canonical.ImageBlock +// content on the user message). +type Notifier func(channelName, message, sessionID string, media []MediaItem) + +// MediaItem is one attachment file already downloaded to a local path. +// Mirrors the host's channel.MediaRef wire shape (path/media_type/name). +type MediaItem struct { + Path string + MediaType string + Name string +} // Supervisor owns the per-(agent, guild) socket.io connections. Run // blocks until ctx is cancelled. @@ -295,7 +306,7 @@ func (s *Supervisor) connectOnce(ctx context.Context, agentID string, guild fabr logger.Warn("inbound: bad message.created", "err", err) return } - s.dispatch(agentID, guild.NodeID, selfUserID, &m, logger) + s.dispatch(agentID, guild.NodeID, guild.Endpoint, selfUserID, &m, logger) }) // Periodic resync goroutine. @@ -332,7 +343,7 @@ func (s *Supervisor) connectOnce(ctx context.Context, agentID string, guild fabr // dispatch applies self-author filter, dedup, channel-binding lookup, // wakeup gate, then notifies Plexum. -func (s *Supervisor) dispatch(agentID, guildNodeID, selfUserID string, m *FabricMessage, logger *slog.Logger) { +func (s *Supervisor) dispatch(agentID, guildNodeID, guildEndpoint, selfUserID string, m *FabricMessage, logger *slog.Logger) { if m.ChannelID == "" { return } @@ -384,20 +395,32 @@ func (s *Supervisor) dispatch(agentID, guildNodeID, selfUserID string, m *Fabric sessionID := "s_fab_" + m.ChannelID body := m.Content plexumChannel := binding.PlexumChannelName - // Attachment download is fire-once-per-message: we'd rather block - // here so the agent's first turn sees the paths, than download - // async and have the agent miss them on the first read. + var media []MediaItem + // Download attachments to local paths so the host can hand them + // to provider plugins as canonical.ImageBlock (Plexum host-side + // media block support). if len(m.Attachments) > 0 && s.Attachments != nil { tok, terr := s.Tokens.GuildToken(context.Background(), agentID, guildNodeID) if terr == nil { refs := make([]attachments.AttachmentRef, len(m.Attachments)) for i, a := range m.Attachments { + // Fabric returns attachment URLs as RELATIVE paths + // (`/api/files/`); resolve against the guild endpoint + // before fetching. + absURL := a.URL + if absURL != "" && absURL[0] == '/' { + absURL = guildEndpoint + absURL + } refs[i] = attachments.AttachmentRef{ - URL: a.URL, Name: a.Name, MimeType: a.MimeType, + URL: absURL, Name: a.Name, MimeType: a.MimeType, } } files := s.Attachments.FetchAll(context.Background(), tok, m.MessageID, refs) - body = attachments.AppendFooter(body, files) + for _, f := range files { + media = append(media, MediaItem{ + Path: f.LocalPath, MediaType: f.MimeType, Name: f.Name, + }) + } if len(files) < len(refs) { logger.Warn("inbound: some attachments failed to download", "requested", len(refs), "got", len(files)) @@ -407,7 +430,7 @@ func (s *Supervisor) dispatch(agentID, guildNodeID, selfUserID string, m *Fabric } } s.enqueueChannel(m.ChannelID, func() { - s.Notify(plexumChannel, body, sessionID) + s.Notify(plexumChannel, body, sessionID, media) }) }