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:
@@ -201,13 +201,25 @@ 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
|
||||
// the host log.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user