// 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//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 //. 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 }