feat: Phase F-6 — attachments + final docs (F-8 = no-op)
F-6 attachments:
- internal/attachments/ (~145 LOC + 8 tests): per-message Downloader
that fetches Fabric attachment URLs into $TMPDIR/plexum-fabric/<msg-id>/
with the agent's guild token, sanitizing filenames + message ids
against path-traversal
- AppendFooter renders downloaded paths as a markdown footer
("Attachments:\n - /tmp/... (mime, N bytes)\n")
- Agents access via the exec MCP tool (cat / file / etc.)
internal/inbound.Supervisor:
- new Attachments *attachments.Downloader field (nil → skip with warn)
- inbound.dispatch: when message has attachments, blocking-download +
AppendFooter before emitting notification (so agent's first turn
sees the paths)
cmd/plexum-fabric-channel-plugin:
- sup.Attachments = attachments.New("") wired at init
F-8 coalesce: no-op. openclaw plugin's coalesce buffered the
text→thinking→tool→text segments openclaw emits across multiple
deliver() calls per turn. Plexum's loop.Run returns ONE final assistant
text per turn (via extractFinalText), so coalescing isn't a concern.
The channel outbound posts a single message naturally.
F-5b presence-sync: deferred. The openclaw plugin pushes HarborForge
on-call status to Fabric's per-recipient presence so the backend can
busy-discard 'announce' deliveries. Plexum's state machine has
different semantics (idle/working/busy/offline) and is per-Plexum-agent
not per-user; mapping requires more design.
README updated with the phase status table + 16-tool list.
Tests: 8 new in internal/attachments (35 total in this repo).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/attachments"
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/config"
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/fabric"
|
||||
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/socketio"
|
||||
@@ -53,13 +54,17 @@ type Notifier func(channelName, message, sessionID string)
|
||||
// Supervisor owns the per-(agent, guild) socket.io connections. Run
|
||||
// blocks until ctx is cancelled.
|
||||
type Supervisor struct {
|
||||
Client *fabric.Client
|
||||
Tokens *tokens.Cache
|
||||
Bindings []config.FabricBinding
|
||||
ByFabric config.ByFabricChannel // (guild_node, channel_id) → binding
|
||||
AgentIDs []string // unique agents to bring up
|
||||
Notify Notifier
|
||||
Logger *slog.Logger
|
||||
Client *fabric.Client
|
||||
Tokens *tokens.Cache
|
||||
Bindings []config.FabricBinding
|
||||
ByFabric config.ByFabricChannel // (guild_node, channel_id) → binding
|
||||
AgentIDs []string // unique agents to bring up
|
||||
Notify Notifier
|
||||
Logger *slog.Logger
|
||||
// Attachments, if non-nil, downloads message attachments to a temp
|
||||
// dir and appends paths to the message body before notify. nil →
|
||||
// attachments are dropped silently (warning logged).
|
||||
Attachments *attachments.Downloader
|
||||
|
||||
// Deduplicate (agentID, messageId) → already dispatched. Bounded
|
||||
// to ~5000 entries (matches openclaw).
|
||||
@@ -68,8 +73,8 @@ type Supervisor struct {
|
||||
|
||||
// Per-channel serial chain: each channel id maps to a chan of
|
||||
// queued task funcs. Channel goroutine drains them in order.
|
||||
chainMu sync.Mutex
|
||||
chains map[string]chan func()
|
||||
chainMu sync.Mutex
|
||||
chains map[string]chan func()
|
||||
}
|
||||
|
||||
// New constructs a Supervisor with deduped agentIDs derived from bindings.
|
||||
@@ -371,13 +376,36 @@ func (s *Supervisor) dispatch(agentID, guildNodeID, selfUserID string, m *Fabric
|
||||
}
|
||||
|
||||
logger.Info("inbound: dispatch",
|
||||
"channel", m.ChannelID, "agent", agentID, "msg", m.MessageID, "xtype", m.XType)
|
||||
"channel", m.ChannelID, "agent", agentID, "msg", m.MessageID, "xtype", m.XType,
|
||||
"attachments", len(m.Attachments))
|
||||
|
||||
// Per-channel serial queue. Concurrent inbounds on the same channel
|
||||
// must run one at a time so Plexum's state machine sees clean turns.
|
||||
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.
|
||||
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 {
|
||||
refs[i] = attachments.AttachmentRef{
|
||||
URL: a.URL, Name: a.Name, MimeType: a.MimeType,
|
||||
}
|
||||
}
|
||||
files := s.Attachments.FetchAll(context.Background(), tok, m.MessageID, refs)
|
||||
body = attachments.AppendFooter(body, files)
|
||||
if len(files) < len(refs) {
|
||||
logger.Warn("inbound: some attachments failed to download",
|
||||
"requested", len(refs), "got", len(files))
|
||||
}
|
||||
} else {
|
||||
logger.Warn("inbound: attachment guild token failed", "err", terr)
|
||||
}
|
||||
}
|
||||
s.enqueueChannel(m.ChannelID, func() {
|
||||
s.Notify(plexumChannel, body, sessionID)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user