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

@@ -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/<id>`); 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)
})
}