Files
Plexum-fabric-channel-plugin/internal/attachments/attachments.go
hzhang 6def33161b 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>
2026-05-31 15:42:23 +01:00

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
}