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())
|
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.
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user