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>
186 lines
4.7 KiB
Go
186 lines
4.7 KiB
Go
// Package attachments downloads Fabric attachment files referenced in
|
|
// inbound messages into a temp dir so the agent can read them via the
|
|
// exec tool (cat / file / etc.).
|
|
//
|
|
// Plexum's notifications/plexum/channel/inbound payload doesn't yet
|
|
// have a structured "media" field (host-side work), so we surface the
|
|
// downloaded paths by appending a markdown footer to the message text:
|
|
//
|
|
// hello, please review the attached file
|
|
//
|
|
// Attachments:
|
|
// - /tmp/plexum-fabric/<msg-id>/proposal.md (text/markdown, 3142 bytes)
|
|
//
|
|
// Agent then uses the exec tool to read whichever paths it cares about.
|
|
package attachments
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DownloadResult tracks one downloaded file.
|
|
type DownloadResult struct {
|
|
LocalPath string
|
|
Name string
|
|
MimeType string
|
|
Size int64
|
|
}
|
|
|
|
// Downloader fetches attachments using a per-call guild token.
|
|
type Downloader struct {
|
|
HTTP *http.Client
|
|
BaseDir string // e.g. /tmp/plexum-fabric
|
|
}
|
|
|
|
// New constructs a Downloader. baseDir defaults to /tmp/plexum-fabric.
|
|
func New(baseDir string) *Downloader {
|
|
if baseDir == "" {
|
|
baseDir = filepath.Join(os.TempDir(), "plexum-fabric")
|
|
}
|
|
return &Downloader{
|
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
|
BaseDir: baseDir,
|
|
}
|
|
}
|
|
|
|
// FetchAll downloads every attachment for messageID; returns the
|
|
// successful results. Per-attachment errors are accumulated as nil
|
|
// entries' "err" field skipped — caller may log via supplied logger.
|
|
// Files land under <BaseDir>/<messageID>/<filename>.
|
|
func (d *Downloader) FetchAll(ctx context.Context, guildToken, messageID string, urls []AttachmentRef) []DownloadResult {
|
|
if len(urls) == 0 {
|
|
return nil
|
|
}
|
|
dir := filepath.Join(d.BaseDir, sanitizeID(messageID))
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return nil
|
|
}
|
|
var out []DownloadResult
|
|
for i, ref := range urls {
|
|
if ref.URL == "" {
|
|
continue
|
|
}
|
|
name := ref.Name
|
|
if name == "" {
|
|
name = fmt.Sprintf("attachment-%d", i)
|
|
}
|
|
name = sanitizeFilename(name)
|
|
dst := filepath.Join(dir, name)
|
|
size, err := d.fetchOne(ctx, guildToken, ref.URL, dst)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, DownloadResult{
|
|
LocalPath: dst, Name: name, MimeType: ref.MimeType, Size: size,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fetchOne downloads ref.URL into dst with auth. Returns bytes written.
|
|
func (d *Downloader) fetchOne(ctx context.Context, guildToken, src, dst string) (int64, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, src, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if guildToken != "" {
|
|
req.Header.Set("authorization", "Bearer "+guildToken)
|
|
}
|
|
resp, err := d.HTTP.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return 0, fmt.Errorf("attachments: %s -> %d", src, resp.StatusCode)
|
|
}
|
|
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer f.Close()
|
|
return io.Copy(f, resp.Body)
|
|
}
|
|
|
|
// AttachmentRef is the minimum the downloader needs per file.
|
|
type AttachmentRef struct {
|
|
URL string
|
|
Name string
|
|
MimeType string
|
|
}
|
|
|
|
// AppendFooter returns body with a markdown footer listing the
|
|
// downloaded attachments (in agent-friendly form). Empty list →
|
|
// body unchanged.
|
|
func AppendFooter(body string, files []DownloadResult) string {
|
|
if len(files) == 0 {
|
|
return body
|
|
}
|
|
var sb strings.Builder
|
|
sb.WriteString(body)
|
|
if !strings.HasSuffix(body, "\n") {
|
|
sb.WriteByte('\n')
|
|
}
|
|
sb.WriteString("\nAttachments:\n")
|
|
for _, f := range files {
|
|
fmt.Fprintf(&sb, " - %s (%s, %d bytes)\n", f.LocalPath, dispMime(f.MimeType), f.Size)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func sanitizeFilename(name string) string {
|
|
// Strip path separators; collapse weird chars. Keep ext.
|
|
name = filepath.Base(name)
|
|
if name == "" || name == "." || name == "/" {
|
|
return "attachment.bin"
|
|
}
|
|
// Reject query strings if any leaked in via name=url.
|
|
if i := strings.IndexAny(name, "?#"); i > 0 {
|
|
name = name[:i]
|
|
}
|
|
if u, err := url.QueryUnescape(name); err == nil {
|
|
name = u
|
|
}
|
|
// Drop any leading dots so we don't end up writing hidden files.
|
|
name = strings.TrimLeft(name, ".")
|
|
if name == "" {
|
|
return "attachment.bin"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func sanitizeID(s string) string {
|
|
// Allow [a-zA-Z0-9_-]; replace anything else with '_'.
|
|
var sb strings.Builder
|
|
sb.Grow(len(s))
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= '0' && r <= '9', r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '-', r == '_':
|
|
sb.WriteRune(r)
|
|
default:
|
|
sb.WriteByte('_')
|
|
}
|
|
}
|
|
if sb.Len() == 0 {
|
|
return "msg"
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func dispMime(m string) string {
|
|
if m == "" {
|
|
return "application/octet-stream"
|
|
}
|
|
return m
|
|
}
|