feat(inbound): emit media in notification instead of appending footer

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/<id>`) 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/<msg>/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 <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-31 21:03:24 +01:00
parent 4674c5a2b7
commit 76a3bfbedb
2 changed files with 48 additions and 13 deletions

View File

@@ -201,13 +201,25 @@ func (p *fabricPlugin) Init(ctx context.Context, host plugin.HostAPI) error {
ctxBg, cancel := context.WithCancel(context.Background()) ctxBg, cancel := context.WithCancel(context.Background())
p.inboundCancel = cancel p.inboundCancel = cancel
p.inboundDone = make(chan struct{}) p.inboundDone = make(chan struct{})
notifier := func(channelName, message, sessionID string) { notifier := func(channelName, message, sessionID string, media []inbound.MediaItem) {
p.host.EmitNotification("notifications/plexum/channel/inbound", map[string]any{ payload := map[string]any{
"channel_name": channelName, "channel_name": channelName,
"message": message, "message": message,
"session_id": sessionID, "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 // slog wrapping plugin.HostAPI.Log isn't worth the indirection
// here; use a discard-style adapter that pipes WARN/INFO to // here; use a discard-style adapter that pipes WARN/INFO to
// the host log. // the host log.

View File

@@ -48,8 +48,19 @@ const (
) )
// Notifier pushes one inbound message to the Plexum host. The plugin // Notifier pushes one inbound message to the Plexum host. The plugin
// main wires this to HostAPI.EmitNotification. // main wires this to HostAPI.EmitNotification. media items are
type Notifier func(channelName, message, sessionID string) // 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 // Supervisor owns the per-(agent, guild) socket.io connections. Run
// blocks until ctx is cancelled. // 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) logger.Warn("inbound: bad message.created", "err", err)
return return
} }
s.dispatch(agentID, guild.NodeID, selfUserID, &m, logger) s.dispatch(agentID, guild.NodeID, guild.Endpoint, selfUserID, &m, logger)
}) })
// Periodic resync goroutine. // 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, // dispatch applies self-author filter, dedup, channel-binding lookup,
// wakeup gate, then notifies Plexum. // 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 == "" { if m.ChannelID == "" {
return return
} }
@@ -384,20 +395,32 @@ func (s *Supervisor) dispatch(agentID, guildNodeID, selfUserID string, m *Fabric
sessionID := "s_fab_" + m.ChannelID sessionID := "s_fab_" + m.ChannelID
body := m.Content body := m.Content
plexumChannel := binding.PlexumChannelName plexumChannel := binding.PlexumChannelName
// Attachment download is fire-once-per-message: we'd rather block var media []MediaItem
// here so the agent's first turn sees the paths, than download // Download attachments to local paths so the host can hand them
// async and have the agent miss them on the first read. // to provider plugins as canonical.ImageBlock (Plexum host-side
// media block support).
if len(m.Attachments) > 0 && s.Attachments != nil { if len(m.Attachments) > 0 && s.Attachments != nil {
tok, terr := s.Tokens.GuildToken(context.Background(), agentID, guildNodeID) tok, terr := s.Tokens.GuildToken(context.Background(), agentID, guildNodeID)
if terr == nil { if terr == nil {
refs := make([]attachments.AttachmentRef, len(m.Attachments)) refs := make([]attachments.AttachmentRef, len(m.Attachments))
for i, a := range m.Attachments { for i, a := range m.Attachments {
// Fabric returns attachment URLs as RELATIVE paths
// (`/api/files/<id>`); resolve against the guild endpoint
// before fetching.
absURL := a.URL
if absURL != "" && absURL[0] == '/' {
absURL = guildEndpoint + absURL
}
refs[i] = attachments.AttachmentRef{ 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) 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) { if len(files) < len(refs) {
logger.Warn("inbound: some attachments failed to download", logger.Warn("inbound: some attachments failed to download",
"requested", len(refs), "got", len(files)) "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.enqueueChannel(m.ChannelID, func() {
s.Notify(plexumChannel, body, sessionID) s.Notify(plexumChannel, body, sessionID, media)
}) })
} }