feat: file attachments and thread visibility (009-attachments-threads)
Web UI: paperclip button for file upload (images, PDFs, text), inline attachment cards with file icon/name/size, image thumbnails with fullscreen overlay, attachment display in thread panel. Threads: always-visible reply count badges on messages, clickable to open thread panel. reply_count and attachments enriched in all API responses via batch queries. MCP: attachments parameter on send_message tool, updated tool descriptions for threading and attachment workflow guidance. Backend: file type validation (allowlist), AttachmentLinker interface to avoid circular deps, GetReplyCounts batch query, EnrichMessages method on MessagingService. Admin CLI: synapbus attachments backup/restore with tar.gz archives, dedup-safe restore. 24 new test cases across 4 packages. All 24 test packages pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3820414166
commit
667b7a4c2e
@@ -103,6 +103,8 @@ make lint # Run linters
|
||||
- Go 1.25+ (per go.mod) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), ory/fosite (OAuth), spf13/cobra (CLI), modernc.org/sqlite (storage), TFMV/hnsw (vectors). NEW: coreos/go-oidc/v3 (OIDC), golang.org/x/oauth2 (OAuth client) (007-platform-features-bundle)
|
||||
- Go 1.25+ (backend), SvelteKit 2 + Svelte 5 (frontend), SvelteKit (website) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), SherClockHolmes/webpush-go (push notifications — NEW) (008-webui-pwa-analytics)
|
||||
- SQLite (existing DB, 1 new migration for push_subscriptions), localStorage (font size) (008-webui-pwa-analytics)
|
||||
- Go 1.25+ (backend), Svelte 5 + Tailwind (frontend) + go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), spf13/cobra (CLI) (009-attachments-threads)
|
||||
- SQLite (modernc.org/sqlite, pure Go) + content-addressable filesystem (SHA-256) (009-attachments-threads)
|
||||
|
||||
## Recent Changes
|
||||
- 002-mcp-auth-ux-polish: Added Go 1.23+ + ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI)
|
||||
|
||||
+72
-43
@@ -1,62 +1,91 @@
|
||||
# Autonomous Execution Summary: SynapBus v0.7.0
|
||||
# Autonomous Implementation Summary: Attachments & Threads Enhancement
|
||||
|
||||
**Branch**: `009-attachments-threads`
|
||||
**Date**: 2026-03-17
|
||||
**Branch**: `008-webui-pwa-analytics`
|
||||
**Status**: Complete — all tests pass, binary builds, endpoints verified
|
||||
**Status**: Complete
|
||||
|
||||
## Features Implemented
|
||||
## What Was Built
|
||||
|
||||
### 1. Analytics Dashboard (P1)
|
||||
- 4 new REST endpoints (`/api/analytics/timeline`, `/top-agents`, `/top-channels`, `/summary`)
|
||||
- SVG bar chart (`AnalyticsChart.svelte`), ranked list (`TopList.svelte`), redesigned dashboard with stat cards and time span selector (1h, 4h, 24h, 7d, 1month)
|
||||
### Attachments (Web UI + MCP + Admin CLI)
|
||||
|
||||
### 2. PWA Conversion (P1)
|
||||
- PWA manifest (`manifest.json`), service worker (`sw.js`), SVG icon
|
||||
- Cache-first for static assets, network-only for API, push notification handling
|
||||
1. **Web UI file upload**: Users can attach files (images, PDFs, text files) when composing messages via a paperclip button in the compose form. Uploaded files show as chips with remove buttons.
|
||||
|
||||
### 3. Push Notifications (P1)
|
||||
- `internal/push/` package — VAPID key generation, Web Push sending, SQLite subscription store
|
||||
- API: `POST/DELETE /api/push/subscribe`, `GET /api/push/vapid-key`
|
||||
- Push toggle in Settings, migration `012_push_subscriptions.sql`
|
||||
2. **Attachment display**: Image attachments render as thumbnails (200x200px max) inline with messages. Clicking opens a fullscreen overlay with close (Escape, click-outside, X button) and download. Non-image files show as file icons with filename, size, and download link.
|
||||
|
||||
### 4. Auto-Resizing Textarea (P1)
|
||||
- ComposeForm textarea auto-grows 3→12 lines, then scrollbar. Resets on send.
|
||||
3. **MCP agent support**: Agents can attach files by uploading via `upload_attachment` action and passing hashes in the new `attachments` parameter on `send_message`. Tool descriptions updated to document the workflow.
|
||||
|
||||
### 5. Smart Mention/Channel Highlighting (P2)
|
||||
- Entities store caches agents/channels. MessageBody validates @mentions and #channels:
|
||||
existing → link, deleted → "inactive" badge, unknown → plain text. Handles email/issue number edge cases.
|
||||
4. **File type validation**: Upload restricted to images (jpg, png, gif, webp, svg), PDFs, and text files (txt, md, csv, json, xml, yaml, log). Invalid types rejected with clear error.
|
||||
|
||||
### 6. Editable Agent Display Name (P2)
|
||||
- Inline edit on agent detail page (click → edit, Enter → save, Escape → cancel)
|
||||
5. **Admin backup/restore**: `synapbus attachments backup --output path.tar.gz` and `synapbus attachments restore --input path.tar.gz` commands. Restore is dedup-safe (skips existing files).
|
||||
|
||||
### 7. Editable Human Display Name (P2)
|
||||
- `PUT /api/auth/profile` endpoint, `UpdateDisplayName` in UserStore, Settings page field
|
||||
### Threads
|
||||
|
||||
### 8. Font Size Preference (P3)
|
||||
- fontSize store (12–24px, 2px steps), -/+ controls in Settings, persisted in localStorage
|
||||
1. **Thread visibility**: Messages with replies show an always-visible "N replies" badge with a chat bubble icon. Clicking opens the thread panel.
|
||||
|
||||
### 9. Version Display (P3)
|
||||
- `GET /api/version` endpoint, version footer in layout linked to GitHub repo
|
||||
2. **Reply count in API**: All message API responses now include `reply_count` (batch-loaded via efficient GROUP BY query).
|
||||
|
||||
### 10. MCP Prompts (P2)
|
||||
- 4 prompts: daily-digest, agent-health-check, channel-overview, debug-agent
|
||||
- `internal/mcp/prompts.go` registered in server.go
|
||||
3. **MCP threading**: `reply_to` parameter description updated to clearly guide agents on threading behavior. Thread context visible in MCP responses.
|
||||
|
||||
### 11. Website Update (P3)
|
||||
- Updated hero/features messaging at ~/repos/synapbus-website/ for individual/small-team positioning
|
||||
4. **Attachment display in threads**: Thread panel also renders attachment thumbnails/file icons.
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Backend (Go)
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `internal/messaging/types.go` | Added `AttachmentInfo` struct, `ReplyCount`, `Attachments` fields to `Message` |
|
||||
| `internal/messaging/options.go` | Added `Attachments []string` to `SendOptions` |
|
||||
| `internal/messaging/store.go` | Added `GetReplyCounts` batch query method + interface |
|
||||
| `internal/messaging/service.go` | Added `AttachmentLinker` interface, `EnrichMessages`, attachment linking in `SendMessage` |
|
||||
| `internal/attachments/model.go` | Added `ErrUnsupportedType` error |
|
||||
| `internal/attachments/mime.go` | Added `IsAllowedType` function |
|
||||
| `internal/attachments/service.go` | Added file type validation in `Upload` |
|
||||
| `internal/api/messages_handler.go` | Accept `attachments[]` in send, `EnrichMessages` in all handlers |
|
||||
| `internal/mcp/tools_hybrid.go` | Added `attachments` param to `send_message`, updated descriptions, enrich responses |
|
||||
| `internal/mcp/bridge.go` | Handle attachments in `callSendMessage`, `callSendChannelMessage` |
|
||||
| `internal/channels/service.go` | Added `attachments` param to `BroadcastMessage` |
|
||||
| `internal/actions/registry.go` | Updated `upload_attachment` description |
|
||||
| `cmd/synapbus/main.go` | Added `attachmentLinkerAdapter`, wired into messaging service |
|
||||
| `cmd/synapbus/admin.go` | Added `backup` and `restore` subcommands |
|
||||
|
||||
### Frontend (Svelte)
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `web/src/lib/api/client.ts` | Added `attachments.upload()`, `attachments` param in `messages.send()` |
|
||||
| `web/src/lib/components/ComposeForm.svelte` | Attachment upload button, file picker, preview chips |
|
||||
| `web/src/lib/components/AttachmentPreview.svelte` | **NEW** — thumbnail + fullscreen overlay component |
|
||||
| `web/src/lib/components/MessageList.svelte` | Attachment display, thread reply count badges |
|
||||
| `web/src/lib/components/ThreadPanel.svelte` | Attachment display in thread messages |
|
||||
|
||||
### Tests
|
||||
| File | New Tests |
|
||||
|------|-----------|
|
||||
| `internal/messaging/store_test.go` | `TestSQLiteMessageStore_GetReplyCounts` (4 subtests) |
|
||||
| `internal/messaging/service_test.go` | `TestMessagingService_EnrichMessages` (3 subtests) |
|
||||
| `internal/attachments/mime_test.go` | `TestIsAllowedType` (12 cases) |
|
||||
| `internal/attachments/service_test.go` | `TestService_Upload_FileTypeValidation` (5 cases) |
|
||||
| `internal/channels/service_test.go` | Updated 13 call sites for new `BroadcastMessage` signature |
|
||||
|
||||
## Test Results
|
||||
|
||||
All 24 Go packages PASS. All API endpoints verified via curl. Web UI builds successfully. Binary compiles with CGO_ENABLED=0.
|
||||
- **Go tests**: 25 packages, all pass, 0 failures
|
||||
- **Integration tests**: 9 E2E tests, all pass
|
||||
- **New tests**: 24 test cases added, all pass
|
||||
- **Web build**: Svelte SPA builds successfully
|
||||
- **Binary build**: 90MB arm64 binary compiles cleanly
|
||||
|
||||
## New Files
|
||||
## Architecture Decisions
|
||||
|
||||
- `internal/api/analytics_handler.go` + test
|
||||
- `internal/api/version_handler.go` + test
|
||||
- `internal/api/push_handler.go`
|
||||
- `internal/push/service.go` + test, `store.go` + test
|
||||
- `internal/mcp/prompts.go` + test
|
||||
- `schema/012_push_subscriptions.sql`
|
||||
- `web/src/lib/components/AnalyticsChart.svelte`, `TopList.svelte`
|
||||
- `web/src/lib/stores/fontSize.ts`, `entities.ts`
|
||||
- `web/static/manifest.json`, `sw.js`, `icons/icon.svg`
|
||||
1. **No circular dependencies**: Used `AttachmentLinker` interface + adapter pattern to avoid messaging->attachments import
|
||||
2. **Batch loading**: Reply counts loaded via single GROUP BY query; attachments loaded per-message (acceptable for LAN scale)
|
||||
3. **Client-side thumbnails**: CSS-only resizing (no server-side image processing, preserves zero-CGO constraint)
|
||||
4. **Zero new migrations**: Leveraged existing `reply_to` column (migration 007) and `attachments` table (migration 001)
|
||||
5. **Zero new dependencies**: All using Go stdlib + existing libraries
|
||||
|
||||
## Constitution Compliance
|
||||
|
||||
All 10 principles satisfied:
|
||||
- I. Local-First: No external dependencies added
|
||||
- II. MCP-Native: Agent features use MCP tools exclusively
|
||||
- III. Pure Go, Zero CGO: stdlib archive/tar + compress/gzip for backup
|
||||
- X. Web UI First-Class: Full attachment and thread UI experience
|
||||
|
||||
+189
-1
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
@@ -987,7 +991,48 @@ func addAdminCommands(rootCmd *cobra.Command) {
|
||||
},
|
||||
}
|
||||
|
||||
attachmentsCmd.AddCommand(attachmentsGCCmd)
|
||||
var attachmentsBackupOutput string
|
||||
var attachmentsBackupDataDir string
|
||||
attachmentsBackupCmd := &cobra.Command{
|
||||
Use: "backup",
|
||||
Short: "Create a tar.gz backup of all attachments (no server required)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
attachDir := filepath.Join(attachmentsBackupDataDir, "attachments")
|
||||
if _, err := os.Stat(attachDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("attachments directory does not exist: %s", attachDir)
|
||||
}
|
||||
fileCount, totalSize, err := backupAttachments(attachDir, attachmentsBackupOutput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
fmt.Printf("Backup complete: %d files, %s total, written to %s\n", fileCount, formatBytes(totalSize), attachmentsBackupOutput)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
attachmentsBackupCmd.Flags().StringVar(&attachmentsBackupOutput, "output", "", "Output path for the tar.gz archive")
|
||||
attachmentsBackupCmd.Flags().StringVar(&attachmentsBackupDataDir, "data", "./data", "Data directory")
|
||||
attachmentsBackupCmd.MarkFlagRequired("output")
|
||||
|
||||
var attachmentsRestoreInput string
|
||||
var attachmentsRestoreDataDir string
|
||||
attachmentsRestoreCmd := &cobra.Command{
|
||||
Use: "restore",
|
||||
Short: "Restore attachments from a tar.gz backup (no server required)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
attachDir := filepath.Join(attachmentsRestoreDataDir, "attachments")
|
||||
restored, skipped, err := restoreAttachments(attachDir, attachmentsRestoreInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore failed: %w", err)
|
||||
}
|
||||
fmt.Printf("Restore complete: %d files restored, %d files skipped (already exist)\n", restored, skipped)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
attachmentsRestoreCmd.Flags().StringVar(&attachmentsRestoreInput, "input", "", "Input path for the tar.gz archive")
|
||||
attachmentsRestoreCmd.Flags().StringVar(&attachmentsRestoreDataDir, "data", "./data", "Data directory")
|
||||
attachmentsRestoreCmd.MarkFlagRequired("input")
|
||||
|
||||
attachmentsCmd.AddCommand(attachmentsGCCmd, attachmentsBackupCmd, attachmentsRestoreCmd)
|
||||
|
||||
// ----- add persistent flag and commands to root -----
|
||||
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "/tmp/synapbus.sock", "Path to admin Unix socket")
|
||||
@@ -1012,3 +1057,146 @@ func toTableRows(data []map[string]string, headerMap map[string]string) []map[st
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// backupAttachments creates a tar.gz archive of the attachments directory.
|
||||
// Returns the number of files archived and total bytes of file content.
|
||||
func backupAttachments(attachmentsDir, outputPath string) (int, int64, error) {
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
gzw := gzip.NewWriter(outFile)
|
||||
defer gzw.Close()
|
||||
|
||||
tw := tar.NewWriter(gzw)
|
||||
defer tw.Close()
|
||||
|
||||
var fileCount int
|
||||
var totalSize int64
|
||||
|
||||
err = filepath.Walk(attachmentsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Skip directories — tar entries for files include the path.
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(attachmentsDir, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relative path: %w", err)
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("file info header: %w", err)
|
||||
}
|
||||
header.Name = relPath
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(tw, f); err != nil {
|
||||
return fmt.Errorf("copy file: %w", err)
|
||||
}
|
||||
|
||||
fileCount++
|
||||
totalSize += info.Size()
|
||||
return nil
|
||||
})
|
||||
|
||||
return fileCount, totalSize, err
|
||||
}
|
||||
|
||||
// restoreAttachments extracts a tar.gz archive into the attachments directory.
|
||||
// Files that already exist on disk are skipped. Returns (restored, skipped) counts.
|
||||
func restoreAttachments(attachmentsDir, inputPath string) (int, int, error) {
|
||||
inFile, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("open input file: %w", err)
|
||||
}
|
||||
defer inFile.Close()
|
||||
|
||||
gzr, err := gzip.NewReader(inFile)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("gzip reader: %w", err)
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
|
||||
var restored, skipped int
|
||||
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return restored, skipped, fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
|
||||
// Only handle regular files.
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize: reject absolute paths and path traversal.
|
||||
cleanName := filepath.Clean(header.Name)
|
||||
if filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, "..") {
|
||||
return restored, skipped, fmt.Errorf("invalid path in archive: %s", header.Name)
|
||||
}
|
||||
|
||||
destPath := filepath.Join(attachmentsDir, cleanName)
|
||||
|
||||
// Skip if already exists (content-addressable, so same hash = same content).
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return restored, skipped, fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
outFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return restored, skipped, fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(outFile, tr); err != nil {
|
||||
outFile.Close()
|
||||
return restored, skipped, fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
outFile.Close()
|
||||
|
||||
restored++
|
||||
}
|
||||
|
||||
return restored, skipped, nil
|
||||
}
|
||||
|
||||
// formatBytes returns a human-readable byte count string.
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
@@ -281,6 +281,7 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
attachmentStore := attachments.NewSQLiteStore(db.DB, slog.Default())
|
||||
attachmentService := attachments.NewService(attachmentStore, cas, slog.Default())
|
||||
msgService.SetAttachmentLinker(&attachmentLinkerAdapter{svc: attachmentService})
|
||||
slog.Info("attachment service initialized", "dir", attachmentsDir)
|
||||
|
||||
// Initialize auth subsystem
|
||||
@@ -836,6 +837,33 @@ func (a *a2aAgentListerAdapter) ListAllActiveAgents(ctx context.Context) ([]a2a.
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// attachmentLinkerAdapter adapts attachments.Service to messaging.AttachmentLinker.
|
||||
type attachmentLinkerAdapter struct {
|
||||
svc *attachments.Service
|
||||
}
|
||||
|
||||
func (a *attachmentLinkerAdapter) AttachToMessage(ctx context.Context, hash string, messageID int64) error {
|
||||
return a.svc.AttachToMessage(ctx, hash, messageID)
|
||||
}
|
||||
|
||||
func (a *attachmentLinkerAdapter) GetByMessageID(ctx context.Context, messageID int64) ([]messaging.AttachmentInfo, error) {
|
||||
atts, err := a.svc.GetByMessageID(ctx, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]messaging.AttachmentInfo, len(atts))
|
||||
for i, att := range atts {
|
||||
results[i] = messaging.AttachmentInfo{
|
||||
Hash: att.Hash,
|
||||
OriginalFilename: att.OriginalFilename,
|
||||
Size: att.Size,
|
||||
MIMEType: att.MIMEType,
|
||||
IsImage: attachments.IsImageType(att.MIMEType),
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// agentListerAdapter adapts agents.AgentService to auth.AgentLister.
|
||||
type agentListerAdapter struct {
|
||||
agentService *agents.AgentService
|
||||
|
||||
@@ -425,7 +425,7 @@ func allActions() []Action {
|
||||
{
|
||||
Name: "upload_attachment",
|
||||
Category: "attachments",
|
||||
Description: "Upload a file attachment. Content must be base64-encoded. Returns the SHA-256 hash for later retrieval. Max file size: 50MB.",
|
||||
Description: "Upload a file attachment. Content must be base64-encoded. Returns the SHA-256 hash for later retrieval. Upload first, then use the returned hash in send_message's attachments parameter to link it to a message. Max file size: 50MB.",
|
||||
Params: []Param{
|
||||
{Name: "content", Type: "string", Description: "Base64-encoded file content", Required: true},
|
||||
{Name: "filename", Type: "string", Description: "Original filename (optional, used for MIME detection and display)"},
|
||||
|
||||
@@ -137,6 +137,8 @@ func (h *AttachmentsHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"empty file not allowed"}`, http.StatusBadRequest)
|
||||
case attachments.ErrFileTooLarge:
|
||||
http.Error(w, `{"error":"file exceeds maximum size of 50MB"}`, http.StatusRequestEntityTooLarge)
|
||||
case attachments.ErrUnsupportedType:
|
||||
http.Error(w, `{"error":"unsupported file type: only images, PDFs, and text files are allowed"}`, http.StatusBadRequest)
|
||||
default:
|
||||
h.logger.Error("upload attachment failed", "error", err)
|
||||
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||
|
||||
@@ -228,6 +228,9 @@ func (h *ChannelsHandler) ChannelMessages(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Enrich messages with reply counts and attachments.
|
||||
h.msgService.EnrichMessages(r.Context(), paginated.Messages)
|
||||
|
||||
// Compute last_read_message_id across owned agents
|
||||
var lastReadMessageID int64
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
|
||||
@@ -86,6 +86,8 @@ func (h *MessagesHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
allMessages = []*messaging.Message{}
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), allMessages)
|
||||
|
||||
sortMessagesByTime(allMessages)
|
||||
if len(allMessages) > limit {
|
||||
allMessages = allMessages[:limit]
|
||||
@@ -122,6 +124,8 @@ func (h *MessagesHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), []*messaging.Message{msg})
|
||||
|
||||
writeJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
@@ -230,6 +234,8 @@ func (h *MessagesHandler) GetConversation(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), messages)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"conversation": conv,
|
||||
"messages": messages,
|
||||
@@ -245,14 +251,15 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Body string `json:"body"`
|
||||
Priority int `json:"priority"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
ReplyTo *int64 `json:"reply_to,omitempty"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Body string `json:"body"`
|
||||
Priority int `json:"priority"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
ReplyTo *int64 `json:"reply_to,omitempty"`
|
||||
Attachments []string `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -307,6 +314,7 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
ConversationID: req.ConversationID,
|
||||
Subject: req.Subject,
|
||||
ReplyTo: req.ReplyTo,
|
||||
Attachments: req.Attachments,
|
||||
}
|
||||
|
||||
msg, err := h.msgService.SendMessage(r.Context(), req.From, req.To, req.Body, opts)
|
||||
@@ -319,6 +327,8 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// SSE broadcast is handled by the MessageListener on the messaging
|
||||
// service, so it fires for both REST and MCP message paths.
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), []*messaging.Message{msg})
|
||||
|
||||
writeJSON(w, http.StatusCreated, msg)
|
||||
}
|
||||
|
||||
@@ -465,6 +475,8 @@ func (h *MessagesHandler) SearchMessages(w http.ResponseWriter, r *http.Request)
|
||||
allMessages = []*messaging.Message{}
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), allMessages)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"messages": allMessages,
|
||||
"query": query,
|
||||
@@ -505,6 +517,8 @@ func (h *MessagesHandler) GetReplies(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), replies)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"replies": replies,
|
||||
"total": len(replies),
|
||||
@@ -550,6 +564,8 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.msgService.EnrichMessages(r.Context(), msgs)
|
||||
|
||||
// Include last_read_message_id for the human agent's DM with the peer
|
||||
lastRead, _ := h.msgService.GetLastReadForDM(r.Context(), agentNames, peerAgent)
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ func TestChannelMessages_IncludesLastRead(t *testing.T) {
|
||||
}
|
||||
|
||||
// Broadcast messages
|
||||
msgs, err := channelService.BroadcastMessage(ctx, ch.ID, "human-agent", "Hello channel", 5, "", nil)
|
||||
msgs, err := channelService.BroadcastMessage(ctx, ch.ID, "human-agent", "Hello channel", 5, "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("broadcast: %v", err)
|
||||
}
|
||||
|
||||
@@ -118,3 +118,27 @@ func DefaultFilename(mimeType string) string {
|
||||
func IsImageType(mimeType string) bool {
|
||||
return imageTypes[mimeType]
|
||||
}
|
||||
|
||||
// IsAllowedType returns true if the MIME type is allowed for upload.
|
||||
// Allowed: image/*, application/pdf, text/*.
|
||||
func IsAllowedType(mimeType string) bool {
|
||||
// Normalize: strip parameters like "; charset=utf-8".
|
||||
base := mimeType
|
||||
if idx := strings.Index(mimeType, ";"); idx >= 0 {
|
||||
base = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
if strings.HasPrefix(base, "image/") {
|
||||
return true
|
||||
}
|
||||
if base == "application/pdf" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(base, "text/") {
|
||||
return true
|
||||
}
|
||||
// Also allow JSON and XML which may be detected as application/*
|
||||
if base == "application/json" || base == "application/xml" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -154,6 +154,35 @@ func TestIsImageType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedType(t *testing.T) {
|
||||
tests := []struct {
|
||||
mimeType string
|
||||
want bool
|
||||
}{
|
||||
{"image/png", true},
|
||||
{"image/jpeg", true},
|
||||
{"image/gif", true},
|
||||
{"application/pdf", true},
|
||||
{"text/plain", true},
|
||||
{"text/csv", true},
|
||||
{"text/plain; charset=utf-8", true},
|
||||
{"application/json", true},
|
||||
{"application/octet-stream", false},
|
||||
{"application/zip", false},
|
||||
{"application/x-executable", false},
|
||||
{"video/mp4", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mimeType, func(t *testing.T) {
|
||||
got := IsAllowedType(tt.mimeType)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsAllowedType(%q) = %v, want %v", tt.mimeType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
|
||||
@@ -11,10 +11,11 @@ const MaxFileSize = 50 * 1024 * 1024 // 50 MB
|
||||
|
||||
// Sentinel errors.
|
||||
var (
|
||||
ErrNotFound = errors.New("attachment not found")
|
||||
ErrFileTooLarge = errors.New("file exceeds maximum size of 50MB")
|
||||
ErrEmptyFile = errors.New("empty file not allowed")
|
||||
ErrFileMissing = errors.New("attachment file missing from disk")
|
||||
ErrNotFound = errors.New("attachment not found")
|
||||
ErrFileTooLarge = errors.New("file exceeds maximum size of 50MB")
|
||||
ErrEmptyFile = errors.New("empty file not allowed")
|
||||
ErrFileMissing = errors.New("attachment file missing from disk")
|
||||
ErrUnsupportedType = errors.New("unsupported file type: only images (jpg, png, gif, webp, svg), PDFs, and text files are allowed")
|
||||
)
|
||||
|
||||
// Attachment represents the metadata for a stored file.
|
||||
|
||||
@@ -60,6 +60,11 @@ func (s *Service) Upload(ctx context.Context, req UploadRequest) (*UploadResult,
|
||||
mimeType = DetectMIMEType(sniffBuf, req.Filename)
|
||||
}
|
||||
|
||||
// Validate file type against allowlist.
|
||||
if !IsAllowedType(mimeType) {
|
||||
return nil, ErrUnsupportedType
|
||||
}
|
||||
|
||||
// Assign default filename if missing.
|
||||
filename := req.Filename
|
||||
if filename == "" {
|
||||
|
||||
@@ -202,6 +202,80 @@ func TestService_Dedup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Upload_FileTypeValidation(t *testing.T) {
|
||||
// PNG magic bytes.
|
||||
pngContent := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00}
|
||||
// PDF magic bytes.
|
||||
pdfContent := []byte("%PDF-1.4 some pdf content here")
|
||||
// Plain text content.
|
||||
textContent := []byte("just some plain text content")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
filename string
|
||||
mimeType string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "valid image upload",
|
||||
content: pngContent,
|
||||
filename: "photo.png",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid PDF upload",
|
||||
content: pdfContent,
|
||||
filename: "report.pdf",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid text file upload",
|
||||
content: textContent,
|
||||
filename: "notes.txt",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid type zip rejected",
|
||||
content: []byte("not real zip content"),
|
||||
filename: "archive.zip",
|
||||
mimeType: "application/zip",
|
||||
wantErr: ErrUnsupportedType,
|
||||
},
|
||||
{
|
||||
name: "invalid type executable rejected",
|
||||
content: []byte{0x7f, 0x45, 0x4c, 0x46},
|
||||
filename: "program.exe",
|
||||
mimeType: "application/x-executable",
|
||||
wantErr: ErrUnsupportedType,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Upload(ctx, UploadRequest{
|
||||
Content: bytes.NewReader(tt.content),
|
||||
Filename: tt.filename,
|
||||
MIMEType: tt.mimeType,
|
||||
UploadedBy: "agent-a",
|
||||
})
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Upload: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GarbageCollect(t *testing.T) {
|
||||
svc, db := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -465,7 +465,7 @@ func (s *Service) UpdateChannel(ctx context.Context, channelID int64, req Update
|
||||
// If the message body contains @mentions, mentioned members receive a
|
||||
// "mention":true flag in their inbox notification metadata, and the channel
|
||||
// message metadata includes "mentioned_agents".
|
||||
func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAgent, body string, priority int, metadata string, replyTo *int64) ([]*messaging.Message, error) {
|
||||
func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAgent, body string, priority int, metadata string, replyTo *int64, attachments []string) ([]*messaging.Message, error) {
|
||||
ch, err := s.store.GetChannel(ctx, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -522,11 +522,12 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
channelMetaBytes, _ := json.Marshal(channelMetaObj)
|
||||
|
||||
channelMsg, err := s.msgService.SendMessage(ctx, fromAgent, "", body, messaging.SendOptions{
|
||||
Subject: fmt.Sprintf("channel:%s", ch.Name),
|
||||
Priority: priority,
|
||||
Metadata: string(channelMetaBytes),
|
||||
ChannelID: &channelID,
|
||||
ReplyTo: replyTo,
|
||||
Subject: fmt.Sprintf("channel:%s", ch.Name),
|
||||
Priority: priority,
|
||||
Metadata: string(channelMetaBytes),
|
||||
ChannelID: &channelID,
|
||||
ReplyTo: replyTo,
|
||||
Attachments: attachments,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create channel message: %w", err)
|
||||
|
||||
@@ -533,7 +533,7 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
ch, _ := svc.CreateChannel(ctx, CreateChannelRequest{Name: "alerts", Type: TypeStandard, CreatedBy: "agent-a"})
|
||||
|
||||
t.Run("broadcast creates channel message", func(t *testing.T) {
|
||||
msgs, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hello", 5, "", nil)
|
||||
msgs, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hello", 5, "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -568,7 +568,7 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
t.Run("broadcast without mentions sends no DMs", func(t *testing.T) {
|
||||
svc.JoinChannel(ctx, ch.ID, "agent-b")
|
||||
svc.JoinChannel(ctx, ch.ID, "agent-c")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no-dm-test", 5, "", nil)
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no-dm-test", 5, "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -583,7 +583,7 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sender does not receive own message", func(t *testing.T) {
|
||||
svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no self-message", 5, "", nil)
|
||||
svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no self-message", 5, "", nil, nil)
|
||||
inboxResult, _ := svc.msgService.ReadInbox(ctx, "agent-a", messaging.ReadOptions{IncludeRead: true})
|
||||
for _, m := range inboxResult.Messages {
|
||||
if m.Body == "no self-message" {
|
||||
@@ -594,7 +594,7 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
|
||||
t.Run("non-member auto-joins public channel on broadcast", func(t *testing.T) {
|
||||
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "auto-joined", 5, "", nil)
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "auto-joined", 5, "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected auto-join for public channel, got %v", err)
|
||||
}
|
||||
@@ -605,10 +605,10 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("broadcast with reply_to", func(t *testing.T) {
|
||||
msgs, _ := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "original", 5, "", nil)
|
||||
msgs, _ := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "original", 5, "", nil, nil)
|
||||
original := msgs[0]
|
||||
|
||||
replies, err := svc.BroadcastMessage(ctx, ch.ID, "agent-b", "reply to original", 5, "", &original.ID)
|
||||
replies, err := svc.BroadcastMessage(ctx, ch.ID, "agent-b", "reply to original", 5, "", &original.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage with reply_to: %v", err)
|
||||
}
|
||||
@@ -625,7 +625,7 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
t.Fatalf("create private channel: %v", err)
|
||||
}
|
||||
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider2")
|
||||
_, err = svc.BroadcastMessage(ctx, privCh.ID, "outsider2", "unauthorized", 5, "", nil)
|
||||
_, err = svc.BroadcastMessage(ctx, privCh.ID, "outsider2", "unauthorized", 5, "", nil, nil)
|
||||
if !errors.Is(err, ErrNotChannelMember) {
|
||||
t.Errorf("expected ErrNotChannelMember for private channel, got %v", err)
|
||||
}
|
||||
@@ -644,7 +644,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
svc.JoinChannel(ctx, ch.ID, "agent-c")
|
||||
|
||||
t.Run("mentioned member gets mention flag in inbox", func(t *testing.T) {
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @agent-b check this", 5, "", nil)
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @agent-b check this", 5, "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -677,7 +677,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("channel message metadata includes mentioned_agents", func(t *testing.T) {
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "cc @agent-b and @agent-c", 5, "", nil)
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "cc @agent-b and @agent-c", 5, "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -707,7 +707,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("self-mention is excluded", func(t *testing.T) {
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "I am @agent-a and cc @agent-b", 5, "", nil)
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "I am @agent-a and cc @agent-b", 5, "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -732,7 +732,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("no mentions produces no mention metadata", func(t *testing.T) {
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "just a normal message", 5, "", nil)
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "just a normal message", 5, "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
@@ -752,7 +752,7 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
|
||||
t.Run("non-member mention is ignored", func(t *testing.T) {
|
||||
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @outsider and @agent-b", 5, "", nil)
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "hey @outsider and @agent-b", 5, "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
|
||||
+50
-6
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
@@ -132,12 +133,34 @@ func (b *ServiceBridge) callSendMessage(ctx context.Context, args map[string]any
|
||||
replyTo = &v
|
||||
}
|
||||
|
||||
var attachmentHashes []string
|
||||
if attVal, ok := args["attachments"]; ok {
|
||||
switch v := attVal.(type) {
|
||||
case string:
|
||||
for _, h := range strings.Split(v, ",") {
|
||||
h = strings.TrimSpace(h)
|
||||
if h != "" {
|
||||
attachmentHashes = append(attachmentHashes, h)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
attachmentHashes = append(attachmentHashes, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
attachmentHashes = v
|
||||
}
|
||||
}
|
||||
|
||||
opts := messaging.SendOptions{
|
||||
Subject: getString(args, "subject", ""),
|
||||
Priority: getInt(args, "priority", 5),
|
||||
Metadata: getString(args, "metadata", ""),
|
||||
ChannelID: channelID,
|
||||
ReplyTo: replyTo,
|
||||
Subject: getString(args, "subject", ""),
|
||||
Priority: getInt(args, "priority", 5),
|
||||
Metadata: getString(args, "metadata", ""),
|
||||
ChannelID: channelID,
|
||||
ReplyTo: replyTo,
|
||||
Attachments: attachmentHashes,
|
||||
}
|
||||
|
||||
msg, err := b.msgService.SendMessage(ctx, b.agentName, to, body, opts)
|
||||
@@ -554,7 +577,28 @@ func (b *ServiceBridge) callSendChannelMessage(ctx context.Context, args map[str
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := b.channelService.BroadcastMessage(ctx, channelID, b.agentName, body, priority, metadata, replyTo)
|
||||
var attachmentHashes []string
|
||||
if attVal, ok := args["attachments"]; ok {
|
||||
switch v := attVal.(type) {
|
||||
case string:
|
||||
for _, h := range strings.Split(v, ",") {
|
||||
h = strings.TrimSpace(h)
|
||||
if h != "" {
|
||||
attachmentHashes = append(attachmentHashes, h)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
attachmentHashes = append(attachmentHashes, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
attachmentHashes = v
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := b.channelService.BroadcastMessage(ctx, channelID, b.agentName, body, priority, metadata, replyTo, attachmentHashes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -84,14 +84,15 @@ func (h *HybridToolRegistrar) myStatusTool() mcplib.Tool {
|
||||
|
||||
func (h *HybridToolRegistrar) sendMessageTool() mcplib.Tool {
|
||||
return mcplib.NewTool("send_message",
|
||||
mcplib.WithDescription("Send a message to another agent (DM) or to a channel. Specify exactly one of 'to' (agent name for DM) or 'channel' (channel name or numeric ID)."),
|
||||
mcplib.WithDescription("Send a message to another agent (DM) or to a channel. Supports attachments — upload files first via the execute tool, then pass the returned hashes here. Specify exactly one of 'to' (agent name for DM) or 'channel' (channel name or numeric ID)."),
|
||||
mcplib.WithString("to", mcplib.Description("Recipient agent name for direct messages")),
|
||||
mcplib.WithString("channel", mcplib.Description("Channel name or numeric ID for channel messages")),
|
||||
mcplib.WithString("body", mcplib.Description("Message body text"), mcplib.Required()),
|
||||
mcplib.WithString("subject", mcplib.Description("Conversation subject (optional)")),
|
||||
mcplib.WithNumber("priority", mcplib.Description("Message priority (1-10, default 5)"), mcplib.Min(1), mcplib.Max(10)),
|
||||
mcplib.WithString("metadata", mcplib.Description("JSON metadata object (optional)")),
|
||||
mcplib.WithNumber("reply_to", mcplib.Description("ID of the message to reply to (optional, for threading)")),
|
||||
mcplib.WithNumber("reply_to", mcplib.Description("ID of the parent message to reply to. Creates a threaded reply. Always use reply_to when responding to a message that is itself a thread reply, to keep conversations organized.")),
|
||||
mcplib.WithString("attachments", mcplib.Description("Comma-separated list of attachment hashes to link to this message. Upload attachments first using the upload_attachment action via the execute tool.")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,6 +324,16 @@ func (h *HybridToolRegistrar) handleSendMessage(ctx context.Context, req mcplib.
|
||||
replyTo = &v
|
||||
}
|
||||
|
||||
var attachmentHashes []string
|
||||
if attStr := req.GetString("attachments", ""); attStr != "" {
|
||||
for _, h := range strings.Split(attStr, ",") {
|
||||
h = strings.TrimSpace(h)
|
||||
if h != "" {
|
||||
attachmentHashes = append(attachmentHashes, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel message path.
|
||||
if channel != "" {
|
||||
if h.channelService == nil {
|
||||
@@ -335,7 +346,7 @@ func (h *HybridToolRegistrar) handleSendMessage(ctx context.Context, req mcplib.
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("send_message to channel failed: %s", err)), nil
|
||||
}
|
||||
|
||||
messages, err := h.channelService.BroadcastMessage(ctx, channelID, agentName, body, priority, metadataStr, replyTo)
|
||||
messages, err := h.channelService.BroadcastMessage(ctx, channelID, agentName, body, priority, metadataStr, replyTo, attachmentHashes)
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("send_message to channel failed: %s", err)), nil
|
||||
}
|
||||
@@ -345,19 +356,30 @@ func (h *HybridToolRegistrar) handleSendMessage(ctx context.Context, req mcplib.
|
||||
messageID = messages[0].ID
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
result := map[string]any{
|
||||
"channel_id": channelID,
|
||||
"message_id": messageID,
|
||||
"status": "sent",
|
||||
})
|
||||
}
|
||||
|
||||
// Enrich channel messages with attachment info.
|
||||
if len(messages) > 0 && len(attachmentHashes) > 0 {
|
||||
h.msgService.EnrichMessages(ctx, messages)
|
||||
if len(messages[0].Attachments) > 0 {
|
||||
result["attachments"] = messages[0].Attachments
|
||||
}
|
||||
}
|
||||
|
||||
return resultJSON(result)
|
||||
}
|
||||
|
||||
// DM path.
|
||||
opts := messaging.SendOptions{
|
||||
Subject: subject,
|
||||
Priority: priority,
|
||||
Metadata: metadataStr,
|
||||
ReplyTo: replyTo,
|
||||
Subject: subject,
|
||||
Priority: priority,
|
||||
Metadata: metadataStr,
|
||||
ReplyTo: replyTo,
|
||||
Attachments: attachmentHashes,
|
||||
}
|
||||
|
||||
msg, err := h.msgService.SendMessage(ctx, agentName, to, body, opts)
|
||||
@@ -365,11 +387,21 @@ func (h *HybridToolRegistrar) handleSendMessage(ctx context.Context, req mcplib.
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("send_message failed: %s", err)), nil
|
||||
}
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
result := map[string]any{
|
||||
"message_id": msg.ID,
|
||||
"conversation_id": msg.ConversationID,
|
||||
"status": msg.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// Enrich message with attachment info.
|
||||
if len(attachmentHashes) > 0 {
|
||||
h.msgService.EnrichMessages(ctx, []*messaging.Message{msg})
|
||||
if len(msg.Attachments) > 0 {
|
||||
result["attachments"] = msg.Attachments
|
||||
}
|
||||
}
|
||||
|
||||
return resultJSON(result)
|
||||
}
|
||||
|
||||
func (h *HybridToolRegistrar) handleSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
|
||||
@@ -2,12 +2,13 @@ package messaging
|
||||
|
||||
// SendOptions configures message sending behavior.
|
||||
type SendOptions struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Metadata string `json:"metadata,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
ReplyTo *int64 `json:"reply_to,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Metadata string `json:"metadata,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
ConversationID *int64 `json:"conversation_id,omitempty"`
|
||||
ReplyTo *int64 `json:"reply_to,omitempty"`
|
||||
Attachments []string `json:"attachments,omitempty"` // attachment hashes to link
|
||||
}
|
||||
|
||||
// ReadOptions configures inbox reading behavior.
|
||||
|
||||
@@ -24,12 +24,20 @@ type MessageListener interface {
|
||||
OnMessageSent(ctx context.Context, msg *Message)
|
||||
}
|
||||
|
||||
// AttachmentLinker links attachment hashes to message IDs. This avoids
|
||||
// importing the attachments package directly. Set via SetAttachmentLinker.
|
||||
type AttachmentLinker interface {
|
||||
AttachToMessage(ctx context.Context, hash string, messageID int64) error
|
||||
GetByMessageID(ctx context.Context, messageID int64) ([]AttachmentInfo, error)
|
||||
}
|
||||
|
||||
// MessagingService provides business logic for messaging operations.
|
||||
type MessagingService struct {
|
||||
store MessageStore
|
||||
tracer *trace.Tracer
|
||||
dispatcher dispatcher.EventDispatcher
|
||||
embeddings EmbeddingNotifier
|
||||
attLinker AttachmentLinker
|
||||
listeners []MessageListener
|
||||
logger *slog.Logger
|
||||
}
|
||||
@@ -53,6 +61,11 @@ func (s *MessagingService) SetEmbeddingNotifier(n EmbeddingNotifier) {
|
||||
s.embeddings = n
|
||||
}
|
||||
|
||||
// SetAttachmentLinker sets the attachment linker for message-attachment binding.
|
||||
func (s *MessagingService) SetAttachmentLinker(l AttachmentLinker) {
|
||||
s.attLinker = l
|
||||
}
|
||||
|
||||
// AddMessageListener registers a listener that is notified after message creation.
|
||||
func (s *MessagingService) AddMessageListener(l MessageListener) {
|
||||
s.listeners = append(s.listeners, l)
|
||||
@@ -145,6 +158,19 @@ func (s *MessagingService) SendMessage(ctx context.Context, from, to, body strin
|
||||
return nil, fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
|
||||
// Link attachments if provided.
|
||||
if s.attLinker != nil && len(opts.Attachments) > 0 {
|
||||
for _, hash := range opts.Attachments {
|
||||
if err := s.attLinker.AttachToMessage(ctx, hash, msg.ID); err != nil {
|
||||
s.logger.Error("failed to link attachment",
|
||||
"hash", hash,
|
||||
"message_id", msg.ID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue for embedding (async, best-effort)
|
||||
if s.embeddings != nil {
|
||||
s.embeddings.OnMessageCreated(ctx, msg.ID, msg.Body)
|
||||
@@ -523,6 +549,44 @@ func (s *MessagingService) GetConversationIDsForDM(ctx context.Context, agentNam
|
||||
return s.store.GetConversationIDsForDM(ctx, agentNames, peerAgent, lastMessageID)
|
||||
}
|
||||
|
||||
// EnrichMessages populates ReplyCount and Attachments on a slice of messages.
|
||||
func (s *MessagingService) EnrichMessages(ctx context.Context, msgs []*Message) {
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]int64, len(msgs))
|
||||
for i, m := range msgs {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
|
||||
// Batch-load reply counts.
|
||||
counts, err := s.store.GetReplyCounts(ctx, ids)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to load reply counts", "error", err)
|
||||
} else {
|
||||
for _, m := range msgs {
|
||||
if c, ok := counts[m.ID]; ok {
|
||||
m.ReplyCount = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-load attachments.
|
||||
if s.attLinker != nil {
|
||||
for _, m := range msgs {
|
||||
atts, err := s.attLinker.GetByMessageID(ctx, m.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to load attachments", "message_id", m.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if len(atts) > 0 {
|
||||
m.Attachments = atts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetConversation returns a conversation and its messages.
|
||||
func (s *MessagingService) GetConversation(ctx context.Context, id int64) (*Conversation, []*Message, error) {
|
||||
conv, err := s.store.GetConversation(ctx, id)
|
||||
|
||||
@@ -728,5 +728,96 @@ func TestMessagingService_ReadInbox_DateFiltering(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// mockAttachmentLinker is a test double for the AttachmentLinker interface.
|
||||
type mockAttachmentLinker struct {
|
||||
attachments map[int64][]AttachmentInfo
|
||||
}
|
||||
|
||||
func (m *mockAttachmentLinker) AttachToMessage(_ context.Context, _ string, _ int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAttachmentLinker) GetByMessageID(_ context.Context, messageID int64) ([]AttachmentInfo, error) {
|
||||
return m.attachments[messageID], nil
|
||||
}
|
||||
|
||||
func TestMessagingService_EnrichMessages(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Send a parent message and replies to it.
|
||||
parent, err := svc.SendMessage(ctx, "sender", "receiver", "parent message", SendOptions{Subject: "Enrich Test"})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage (parent): %v", err)
|
||||
}
|
||||
|
||||
replyTo := parent.ID
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := svc.SendMessage(ctx, "receiver", "sender", "reply", SendOptions{
|
||||
Subject: "Enrich Test",
|
||||
ReplyTo: &replyTo,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage (reply %d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send a message with no replies.
|
||||
noReplies, err := svc.SendMessage(ctx, "sender", "receiver", "standalone", SendOptions{Subject: "Enrich Standalone"})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage (standalone): %v", err)
|
||||
}
|
||||
|
||||
t.Run("reply counts populated", func(t *testing.T) {
|
||||
msgs := []*Message{parent, noReplies}
|
||||
svc.EnrichMessages(ctx, msgs)
|
||||
|
||||
if parent.ReplyCount != 3 {
|
||||
t.Errorf("parent ReplyCount = %d, want 3", parent.ReplyCount)
|
||||
}
|
||||
if noReplies.ReplyCount != 0 {
|
||||
t.Errorf("noReplies ReplyCount = %d, want 0", noReplies.ReplyCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("attachments populated when linker set", func(t *testing.T) {
|
||||
linker := &mockAttachmentLinker{
|
||||
attachments: map[int64][]AttachmentInfo{
|
||||
parent.ID: {
|
||||
{Hash: "abc123", OriginalFilename: "photo.png", Size: 1024, MIMEType: "image/png", IsImage: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc.SetAttachmentLinker(linker)
|
||||
|
||||
// Reset enrichment state.
|
||||
parent.ReplyCount = 0
|
||||
parent.Attachments = nil
|
||||
noReplies.ReplyCount = 0
|
||||
noReplies.Attachments = nil
|
||||
|
||||
msgs := []*Message{parent, noReplies}
|
||||
svc.EnrichMessages(ctx, msgs)
|
||||
|
||||
if parent.ReplyCount != 3 {
|
||||
t.Errorf("parent ReplyCount = %d, want 3", parent.ReplyCount)
|
||||
}
|
||||
if len(parent.Attachments) != 1 {
|
||||
t.Fatalf("parent Attachments count = %d, want 1", len(parent.Attachments))
|
||||
}
|
||||
if parent.Attachments[0].Hash != "abc123" {
|
||||
t.Errorf("attachment hash = %s, want abc123", parent.Attachments[0].Hash)
|
||||
}
|
||||
if noReplies.Attachments != nil {
|
||||
t.Errorf("noReplies Attachments should be nil, got %v", noReplies.Attachments)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty slice is a no-op", func(t *testing.T) {
|
||||
svc.EnrichMessages(ctx, []*Message{})
|
||||
// Should not panic or error.
|
||||
})
|
||||
}
|
||||
|
||||
// suppress unused import warning for storage package
|
||||
var _ = storage.RunMigrations
|
||||
|
||||
@@ -39,6 +39,7 @@ type MessageStore interface {
|
||||
GetLastReadForDM(ctx context.Context, agentNames []string, peerAgent string) (int64, error)
|
||||
GetConversationIDsForChannel(ctx context.Context, channelID int64, lastMessageID int64) ([]int64, error)
|
||||
GetConversationIDsForDM(ctx context.Context, agentNames []string, peerAgent string, lastMessageID int64) ([]int64, error)
|
||||
GetReplyCounts(ctx context.Context, messageIDs []int64) (map[int64]int, error)
|
||||
}
|
||||
|
||||
// SQLiteMessageStore implements MessageStore using SQLite.
|
||||
@@ -1007,6 +1008,44 @@ func (s *SQLiteMessageStore) GetConversationIDsForDM(ctx context.Context, agentN
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// GetReplyCounts returns a map of message ID → reply count for the given IDs.
|
||||
func (s *SQLiteMessageStore) GetReplyCounts(ctx context.Context, messageIDs []int64) (map[int64]int, error) {
|
||||
if len(messageIDs) == 0 {
|
||||
return map[int64]int{}, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(messageIDs))
|
||||
args := make([]any, len(messageIDs))
|
||||
for i, id := range messageIDs {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT reply_to, COUNT(*) FROM messages
|
||||
WHERE reply_to IN (%s)
|
||||
GROUP BY reply_to`,
|
||||
strings.Join(placeholders, ","),
|
||||
)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get reply counts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
counts := make(map[int64]int)
|
||||
for rows.Next() {
|
||||
var replyTo int64
|
||||
var count int
|
||||
if err := rows.Scan(&replyTo, &count); err != nil {
|
||||
return nil, fmt.Errorf("scan reply count: %w", err)
|
||||
}
|
||||
counts[replyTo] = count
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// scanMessage scans a single message from sql.Row.
|
||||
func scanMessage(row *sql.Row) (*Message, error) {
|
||||
var msg Message
|
||||
|
||||
@@ -1027,6 +1027,132 @@ func TestSQLiteMessageStore_GetChannelMessages_Offset(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSQLiteMessageStore_GetReplyCounts(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteMessageStore(db)
|
||||
ctx := context.Background()
|
||||
|
||||
seedAgent(t, db, "sender")
|
||||
seedAgent(t, db, "replier")
|
||||
|
||||
conv := &Conversation{Subject: "reply counts", CreatedBy: "sender"}
|
||||
if err := store.InsertConversation(ctx, conv); err != nil {
|
||||
t.Fatalf("InsertConversation: %v", err)
|
||||
}
|
||||
|
||||
// Insert parent message
|
||||
parent := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "replier",
|
||||
Body: "parent message",
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, parent); err != nil {
|
||||
t.Fatalf("InsertMessage (parent): %v", err)
|
||||
}
|
||||
|
||||
// Insert 3 replies to parent
|
||||
for i := 0; i < 3; i++ {
|
||||
reply := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "replier",
|
||||
ToAgent: "sender",
|
||||
ReplyTo: &parent.ID,
|
||||
Body: fmt.Sprintf("reply %d", i),
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, reply); err != nil {
|
||||
t.Fatalf("InsertMessage (reply %d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("parent with 3 replies", func(t *testing.T) {
|
||||
counts, err := store.GetReplyCounts(ctx, []int64{parent.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReplyCounts: %v", err)
|
||||
}
|
||||
if counts[parent.ID] != 3 {
|
||||
t.Errorf("reply count for parent = %d, want 3", counts[parent.ID])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("message with no replies returns 0", func(t *testing.T) {
|
||||
// Insert a message with no replies
|
||||
noReply := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "replier",
|
||||
Body: "no replies here",
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, noReply); err != nil {
|
||||
t.Fatalf("InsertMessage (noReply): %v", err)
|
||||
}
|
||||
|
||||
counts, err := store.GetReplyCounts(ctx, []int64{noReply.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReplyCounts: %v", err)
|
||||
}
|
||||
if counts[noReply.ID] != 0 {
|
||||
t.Errorf("reply count for noReply = %d, want 0", counts[noReply.ID])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple parents", func(t *testing.T) {
|
||||
// Insert a second parent with 2 replies
|
||||
parent2 := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "sender",
|
||||
ToAgent: "replier",
|
||||
Body: "second parent",
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, parent2); err != nil {
|
||||
t.Fatalf("InsertMessage (parent2): %v", err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
reply := &Message{
|
||||
ConversationID: conv.ID,
|
||||
FromAgent: "replier",
|
||||
ToAgent: "sender",
|
||||
ReplyTo: &parent2.ID,
|
||||
Body: fmt.Sprintf("reply to parent2 %d", i),
|
||||
Priority: 5,
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.InsertMessage(ctx, reply); err != nil {
|
||||
t.Fatalf("InsertMessage (parent2 reply %d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
counts, err := store.GetReplyCounts(ctx, []int64{parent.ID, parent2.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReplyCounts: %v", err)
|
||||
}
|
||||
if counts[parent.ID] != 3 {
|
||||
t.Errorf("reply count for parent = %d, want 3", counts[parent.ID])
|
||||
}
|
||||
if counts[parent2.ID] != 2 {
|
||||
t.Errorf("reply count for parent2 = %d, want 2", counts[parent2.ID])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty slice returns empty map", func(t *testing.T) {
|
||||
counts, err := store.GetReplyCounts(ctx, []int64{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReplyCounts: %v", err)
|
||||
}
|
||||
if len(counts) != 0 {
|
||||
t.Errorf("expected empty map, got %v", counts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSQLiteMessageStore_CombinedFiltersAndPagination(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteMessageStore(db)
|
||||
|
||||
@@ -14,6 +14,16 @@ const (
|
||||
StatusFailed = "failed"
|
||||
)
|
||||
|
||||
// AttachmentInfo is a lightweight attachment summary included in message responses.
|
||||
// It avoids importing the attachments package into the messaging package.
|
||||
type AttachmentInfo struct {
|
||||
Hash string `json:"hash"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Size int64 `json:"size"`
|
||||
MIMEType string `json:"mime_type"`
|
||||
IsImage bool `json:"is_image"`
|
||||
}
|
||||
|
||||
// Message represents a single message in the system.
|
||||
type Message struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -30,6 +40,8 @@ type Message struct {
|
||||
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ReplyCount int `json:"reply_count"`
|
||||
Attachments []AttachmentInfo `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// Conversation groups related messages into a thread.
|
||||
|
||||
Vendored
+11
-11
@@ -11,30 +11,30 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.Brt_7G-i.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B_GGgm-X.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.BmgMnErP.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/i7g3n1jh.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CAWWRzNH.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B-GqcZrE.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DpGxxGDS.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/3PInzq4O.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/qakGnb92.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.n45Hd8qT.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/D2RS6Hlp.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C-40UA0L.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/4Fzv7G3k.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C0W-VSVk.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/ChDFVqbV.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.BVlIPsSc.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_nj28a9 = {
|
||||
__sveltekit_nqckuv = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.Brt_7G-i.js"),
|
||||
import("/_app/immutable/entry/app.n45Hd8qT.js")
|
||||
import("/_app/immutable/entry/start.BmgMnErP.js"),
|
||||
import("/_app/immutable/entry/app.BVlIPsSc.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Specification Quality Checklist: Attachments & Threads Enhancement
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-17
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass validation. Spec is ready for `/speckit.plan`.
|
||||
- Assumptions section documents all decisions made where details were ambiguous.
|
||||
- 6 user stories cover: web UI upload, image preview/fullscreen, thread visibility, agent attachments, agent threads, admin backup/restore.
|
||||
- 18 functional requirements, 8 success criteria, 7 edge cases documented.
|
||||
@@ -0,0 +1,100 @@
|
||||
# REST API Contract Changes
|
||||
|
||||
## Modified Endpoints
|
||||
|
||||
### POST /api/messages — Send Message (Modified)
|
||||
|
||||
**Request Body** (new field: `attachments`):
|
||||
```json
|
||||
{
|
||||
"to": "agent-name",
|
||||
"channel": "channel-name",
|
||||
"body": "Message text",
|
||||
"subject": "Optional subject",
|
||||
"priority": 5,
|
||||
"reply_to": 123,
|
||||
"attachments": ["sha256hash1", "sha256hash2"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (enriched with attachments and reply_count):
|
||||
```json
|
||||
{
|
||||
"id": 456,
|
||||
"conversation_id": 789,
|
||||
"from_agent": "sender",
|
||||
"body": "Message text",
|
||||
"reply_to": 123,
|
||||
"reply_count": 0,
|
||||
"attachments": [
|
||||
{
|
||||
"hash": "sha256hash1",
|
||||
"original_filename": "image.png",
|
||||
"size": 204800,
|
||||
"mime_type": "image/png",
|
||||
"is_image": true
|
||||
}
|
||||
],
|
||||
"created_at": "2026-03-17T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/messages — List Messages (Modified Response)
|
||||
|
||||
Each message in the array now includes `reply_count` (integer) and `attachments` (array).
|
||||
|
||||
### GET /api/channels/{name}/messages — Channel Messages (Modified Response)
|
||||
|
||||
Each message includes `reply_count` and `attachments`.
|
||||
|
||||
### GET /api/messages/{id}/replies — Thread Replies (Existing, Unchanged)
|
||||
|
||||
Returns array of reply messages.
|
||||
|
||||
## Existing Endpoints (Unchanged)
|
||||
|
||||
### POST /api/attachments — Upload
|
||||
|
||||
Multipart form upload. Returns hash, size, mime_type, filename.
|
||||
|
||||
### GET /api/attachments/{hash} — Download
|
||||
|
||||
Streams file content with Content-Type header.
|
||||
|
||||
### GET /api/attachments/{hash}/meta — Metadata
|
||||
|
||||
Returns attachment metadata JSON.
|
||||
|
||||
## MCP Tool Changes
|
||||
|
||||
### send_message (Modified)
|
||||
|
||||
New parameter: `attachments` — array of SHA-256 hashes of previously uploaded attachments.
|
||||
|
||||
Updated description for `reply_to`: "ID of the parent message to reply to. Creates a threaded reply. Always use reply_to when responding to a message that was itself a thread reply, to keep the conversation organized."
|
||||
|
||||
### upload_attachment (Existing)
|
||||
|
||||
No changes. Parameters: content (base64), filename, mime_type, message_id.
|
||||
|
||||
### download_attachment (Existing)
|
||||
|
||||
No changes. Parameter: hash.
|
||||
|
||||
## Admin CLI Changes
|
||||
|
||||
### New: `synapbus attachments backup`
|
||||
|
||||
```
|
||||
synapbus attachments backup --output /path/to/backup.tar.gz [--socket /path/to/socket]
|
||||
```
|
||||
|
||||
Creates tar.gz of attachment storage directory.
|
||||
|
||||
### New: `synapbus attachments restore`
|
||||
|
||||
```
|
||||
synapbus attachments restore --input /path/to/backup.tar.gz [--socket /path/to/socket]
|
||||
```
|
||||
|
||||
Restores attachment files, skipping existing (dedup-safe).
|
||||
@@ -0,0 +1,119 @@
|
||||
# Data Model: Attachments & Threads Enhancement
|
||||
|
||||
**Branch**: `009-attachments-threads` | **Date**: 2026-03-17
|
||||
|
||||
## Entity Changes
|
||||
|
||||
### Message (Modified)
|
||||
|
||||
Existing table `messages` — already has `reply_to` column via migration 007.
|
||||
|
||||
**New computed field** (not stored, calculated in queries):
|
||||
- `reply_count` (integer): COUNT of messages where reply_to = this message's ID
|
||||
|
||||
**New field in API response** (joined from attachments table):
|
||||
- `attachments` (array of Attachment): All attachments linked to this message via message_id FK
|
||||
|
||||
### Attachment (Existing, No Schema Changes)
|
||||
|
||||
Table `attachments` — no changes needed. Existing schema:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INTEGER PK | Auto-increment |
|
||||
| hash | TEXT NOT NULL | SHA-256 content hash |
|
||||
| original_filename | TEXT NOT NULL | Original upload filename |
|
||||
| size | INTEGER NOT NULL | File size in bytes |
|
||||
| mime_type | TEXT NOT NULL | Detected MIME type |
|
||||
| message_id | INTEGER FK | Nullable, references messages(id) |
|
||||
| uploaded_by | TEXT NOT NULL | Agent/user who uploaded |
|
||||
| created_at | TIMESTAMP | Upload timestamp |
|
||||
|
||||
### Relationships
|
||||
|
||||
```
|
||||
Message 1 ──── 0..* Attachment (via attachment.message_id)
|
||||
Message 1 ──── 0..* Message (via message.reply_to → parent message.id)
|
||||
```
|
||||
|
||||
## Query Changes
|
||||
|
||||
### Message List with Reply Count
|
||||
|
||||
```sql
|
||||
SELECT m.*,
|
||||
COALESCE(rc.reply_count, 0) as reply_count
|
||||
FROM messages m
|
||||
LEFT JOIN (
|
||||
SELECT reply_to, COUNT(*) as reply_count
|
||||
FROM messages
|
||||
WHERE reply_to IS NOT NULL
|
||||
GROUP BY reply_to
|
||||
) rc ON rc.reply_to = m.id
|
||||
WHERE ...
|
||||
```
|
||||
|
||||
### Message with Attachments
|
||||
|
||||
```sql
|
||||
SELECT a.id, a.hash, a.original_filename, a.size, a.mime_type, a.created_at
|
||||
FROM attachments a
|
||||
WHERE a.message_id = ?
|
||||
ORDER BY a.created_at ASC
|
||||
```
|
||||
|
||||
### Thread Replies
|
||||
|
||||
Already exists in store.go:
|
||||
```sql
|
||||
SELECT ... FROM messages WHERE reply_to = ? ORDER BY created_at ASC
|
||||
```
|
||||
|
||||
## API Response Changes
|
||||
|
||||
### Message Response (enriched)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"body": "Hello",
|
||||
"from_agent": "research-bot",
|
||||
"reply_to": null,
|
||||
"reply_count": 3,
|
||||
"attachments": [
|
||||
{
|
||||
"hash": "abc123...",
|
||||
"original_filename": "report.pdf",
|
||||
"size": 1048576,
|
||||
"mime_type": "application/pdf",
|
||||
"is_image": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Send Message Request (enriched)
|
||||
|
||||
```json
|
||||
{
|
||||
"to": "agent-name",
|
||||
"body": "See attached report",
|
||||
"reply_to": 456,
|
||||
"attachments": ["abc123...", "def456..."]
|
||||
}
|
||||
```
|
||||
|
||||
## File Storage (No Changes)
|
||||
|
||||
Content-addressable storage structure (existing):
|
||||
```
|
||||
{dataDir}/attachments/
|
||||
├── ab/
|
||||
│ └── cd/
|
||||
│ └── abcd1234... (full SHA-256 hash as filename)
|
||||
├── ef/
|
||||
│ └── 01/
|
||||
│ └── ef012345...
|
||||
```
|
||||
|
||||
Backup archive mirrors this structure in tar.gz format.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Implementation Plan: Attachments & Threads Enhancement
|
||||
|
||||
**Branch**: `009-attachments-threads` | **Date**: 2026-03-17 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/009-attachments-threads/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Enhance SynapBus with web UI file attachment support (upload, thumbnail preview, fullscreen view, download), fix thread visibility (reply count badges, thread indicators), update MCP tools for agent attachment/thread workflows, and add admin CLI backup/restore for attachment files. The attachment backend (CAS, SQLite metadata) already exists; this feature adds the UI layer, enriches API responses, and fills thread visibility gaps.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25+ (backend), Svelte 5 + Tailwind (frontend)
|
||||
**Primary Dependencies**: go-chi/chi (HTTP), mark3labs/mcp-go (MCP), modernc.org/sqlite (storage), spf13/cobra (CLI)
|
||||
**Storage**: SQLite (modernc.org/sqlite, pure Go) + content-addressable filesystem (SHA-256)
|
||||
**Testing**: `go test ./...` (backend), manual + curl (API), Chrome (UI)
|
||||
**Target Platform**: linux/amd64, darwin/arm64 (cross-compiled single binary)
|
||||
**Project Type**: Web service with embedded SPA
|
||||
**Performance Goals**: File uploads complete in <10s for files under 10MB; thumbnails render in <1s
|
||||
**Constraints**: Zero CGO, single binary, single --data directory
|
||||
**Scale/Scope**: LAN-scale (tens of agents, hundreds of messages/day)
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | PASS | All changes are within the single binary. No external dependencies added. |
|
||||
| II. MCP-Native | PASS | Agent attachment/thread workflows use MCP tools. REST API changes are for Web UI only. |
|
||||
| III. Pure Go, Zero CGO | PASS | No new CGO dependencies. Image thumbnails are CSS-only (client-side). Backup uses stdlib archive/tar + compress/gzip. |
|
||||
| IV. Multi-Tenant with Ownership | PASS | Attachments track uploaded_by. Message ownership unchanged. |
|
||||
| V. Embedded OAuth 2.1 | N/A | No auth changes. |
|
||||
| VI. Semantic-Ready Storage | N/A | No search changes. |
|
||||
| VII. Swarm Intelligence | N/A | No swarm pattern changes. |
|
||||
| VIII. Observable by Default | PASS | Attachment uploads and thread replies are already traced. |
|
||||
| IX. Progressive Complexity | PASS | Attachments are tier 3 (advanced). Thread fixes improve existing basic messaging. |
|
||||
| X. Web UI as First-Class Citizen | PASS | This feature specifically enhances the Web UI. |
|
||||
|
||||
**Post-design re-check**: All principles still satisfied. No violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/009-attachments-threads/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Research decisions
|
||||
├── data-model.md # Data model changes
|
||||
├── quickstart.md # Testing quickstart
|
||||
├── contracts/ # API contract changes
|
||||
│ └── rest-api.md
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md # Task breakdown (generated by /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
# Backend (Go)
|
||||
internal/
|
||||
├── attachments/
|
||||
│ └── service.go # Add file type validation
|
||||
├── messaging/
|
||||
│ ├── store.go # Add reply_count to queries, attachment loading
|
||||
│ ├── service.go # Add attachment linking on send
|
||||
│ └── types.go # Add ReplyCount, Attachments fields to Message
|
||||
├── api/
|
||||
│ ├── messages_handler.go # Accept attachments[] in send, return enriched messages
|
||||
│ └── attachments_handler.go # (existing, minimal changes)
|
||||
└── mcp/
|
||||
├── tools_hybrid.go # Add attachments param, update descriptions
|
||||
└── bridge.go # Handle attachments in send flow
|
||||
|
||||
cmd/synapbus/
|
||||
└── admin.go # Add backup/restore subcommands
|
||||
|
||||
# Frontend (Svelte)
|
||||
web/src/lib/
|
||||
├── components/
|
||||
│ ├── ComposeForm.svelte # Add attachment upload button + preview
|
||||
│ ├── MessageList.svelte # Add thread indicator badges
|
||||
│ ├── MessageBody.svelte # Add attachment display (thumbnails, file icons)
|
||||
│ ├── AttachmentPreview.svelte # NEW: thumbnail + fullscreen component
|
||||
│ └── ThreadPanel.svelte # Existing, minor fixes
|
||||
├── stores/
|
||||
│ └── thread.ts # Existing, no changes
|
||||
└── api/
|
||||
└── client.ts # Add attachment upload method
|
||||
|
||||
# Tests
|
||||
internal/attachments/service_test.go # File type validation tests
|
||||
internal/messaging/store_test.go # Reply count query tests
|
||||
internal/messaging/service_test.go # Attachment linking tests
|
||||
internal/api/messages_handler_test.go # API enrichment tests
|
||||
internal/mcp/tools_hybrid_test.go # MCP attachment param tests
|
||||
cmd/synapbus/admin_test.go # Backup/restore tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Existing Go + Svelte project structure. No new packages needed — all changes extend existing packages. One new Svelte component (AttachmentPreview.svelte) for image thumbnail/fullscreen display.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations. No complexity justifications needed.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Quickstart: Attachments & Threads Enhancement
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.23+ installed
|
||||
- Node.js 18+ for Svelte UI development
|
||||
- Running SynapBus instance with data directory
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
# Build everything
|
||||
make build
|
||||
make web
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Run with hot reload for development
|
||||
make dev
|
||||
```
|
||||
|
||||
## Testing Attachments
|
||||
|
||||
### Upload via curl
|
||||
|
||||
```bash
|
||||
# Upload a file
|
||||
curl -X POST http://localhost:8080/api/attachments \
|
||||
-H "Cookie: session=YOUR_SESSION" \
|
||||
-F "file=@/path/to/image.png"
|
||||
|
||||
# Response: {"hash": "abc123...", "size": 204800, "mime_type": "image/png", "original_filename": "image.png"}
|
||||
```
|
||||
|
||||
### Send message with attachment
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/messages \
|
||||
-H "Cookie: session=YOUR_SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"channel": "general",
|
||||
"body": "Check this image",
|
||||
"attachments": ["abc123..."]
|
||||
}'
|
||||
```
|
||||
|
||||
### Download attachment
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/attachments/abc123... -o downloaded.png
|
||||
```
|
||||
|
||||
## Testing Threads
|
||||
|
||||
### Send a reply
|
||||
|
||||
```bash
|
||||
# Send initial message
|
||||
curl -X POST http://localhost:8080/api/messages \
|
||||
-H "Cookie: session=YOUR_SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"channel": "general", "body": "Original message"}'
|
||||
|
||||
# Reply to message ID 123
|
||||
curl -X POST http://localhost:8080/api/messages \
|
||||
-H "Cookie: session=YOUR_SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"channel": "general", "body": "Thread reply", "reply_to": 123}'
|
||||
```
|
||||
|
||||
### View thread replies
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/messages/123/replies \
|
||||
-H "Cookie: session=YOUR_SESSION"
|
||||
```
|
||||
|
||||
## Testing via MCP
|
||||
|
||||
### Upload attachment (MCP)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "upload_attachment",
|
||||
"args": {
|
||||
"content": "<base64-encoded-data>",
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Send message with attachment and reply_to (MCP)
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "send_message",
|
||||
"args": {
|
||||
"channel": "general",
|
||||
"body": "See attached report",
|
||||
"reply_to": 123,
|
||||
"attachments": ["abc123..."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Backup/Restore
|
||||
|
||||
```bash
|
||||
# Backup attachments
|
||||
./synapbus attachments backup --output /backup/attachments-2026-03-17.tar.gz
|
||||
|
||||
# Restore attachments
|
||||
./synapbus attachments restore --input /backup/attachments-2026-03-17.tar.gz
|
||||
```
|
||||
|
||||
## Web UI
|
||||
|
||||
1. Open http://localhost:8080 and log in
|
||||
2. Navigate to a channel
|
||||
3. Click the paperclip icon in the compose area to attach a file
|
||||
4. Send a message — attached images show as thumbnails
|
||||
5. Click a thumbnail to see fullscreen view
|
||||
6. Messages with replies show "N replies" badge — click to open thread panel
|
||||
@@ -0,0 +1,70 @@
|
||||
# Research: Attachments & Threads Enhancement
|
||||
|
||||
**Branch**: `009-attachments-threads` | **Date**: 2026-03-17
|
||||
|
||||
## Decision 1: Migration Location for reply_to
|
||||
|
||||
**Decision**: Migration `007_threads.sql` already exists in `internal/storage/schema/` adding `reply_to INTEGER` column to messages table with an index. No new migration needed for the column itself.
|
||||
|
||||
**Rationale**: The Go migration loader reads from `internal/storage/schema/` (embedded via go:embed). The `schema/` directory at repo root is for documentation/reference. The column already exists in the running system.
|
||||
|
||||
**What's missing**: Reply count is not returned in message list API responses. Need to add `reply_count` as a computed field (COUNT subquery or LEFT JOIN) to message queries so the UI can show thread indicators.
|
||||
|
||||
## Decision 2: Attachment-Message Linking Flow
|
||||
|
||||
**Decision**: Use a two-step flow: (1) upload attachment → get hash, (2) send message with `attachments` field containing hash array. The `AttachToMessage` service method links attachments after message creation.
|
||||
|
||||
**Rationale**: The existing upload endpoint returns a hash. The existing `AttachToMessage(hash, messageID)` method exists but is not exposed in REST API. Need to:
|
||||
1. Add `attachments` field to the send message API request
|
||||
2. After message insert, call `AttachToMessage` for each hash
|
||||
3. Include attachments in message API responses (query by message_id)
|
||||
|
||||
**Alternatives considered**:
|
||||
- Single multipart upload with message: Too complex for MCP agents, breaks the existing API pattern
|
||||
- Store attachment hashes in message metadata JSON: Loses relational integrity, harder to query
|
||||
|
||||
## Decision 3: Thumbnail Generation
|
||||
|
||||
**Decision**: Client-side only. Use CSS `max-width: 200px; max-height: 200px; object-fit: cover` on `<img>` tags pointing to the attachment download URL.
|
||||
|
||||
**Rationale**: Server-side thumbnail generation would require image processing libraries (likely CGO, violating Principle III). The original images are served directly; the browser handles resizing. For large images, this means downloading the full file for thumbnails — acceptable for a local-first system on LAN.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Server-side thumbnails with pure Go image library: Adds complexity, new dependency, storage overhead
|
||||
- Lazy loading with IntersectionObserver: Good optimization to add but not core requirement
|
||||
|
||||
## Decision 4: Fullscreen Image Overlay
|
||||
|
||||
**Decision**: Simple Svelte component with fixed overlay, `<img>` at natural size (max viewport), close button, download button. Dismiss via Escape, click outside, or close button.
|
||||
|
||||
**Rationale**: Minimal implementation that meets requirements. No carousel needed (one image at a time). No zoom/pan needed for v1.
|
||||
|
||||
## Decision 5: Admin Backup/Restore
|
||||
|
||||
**Decision**: `synapbus attachments backup --output path.tar.gz` and `synapbus attachments restore --input path.tar.gz`. Archive the CAS directory structure preserving the 2-level shard paths.
|
||||
|
||||
**Rationale**: The CAS directory is self-contained (`{dataDir}/attachments/`). tar.gz preserves directory structure and is universally supported. Restore skips existing files (same hash = same content = dedup).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Backup via admin socket: Useful for remote backup but adds complexity; CLI direct file access is simpler
|
||||
- Include SQLite metadata in backup: Not needed — metadata can be reconstructed from CAS files + existing DB
|
||||
|
||||
## Decision 6: Thread Visibility in UI
|
||||
|
||||
**Decision**: Add reply_count to message list responses. Show a clickable "N replies" badge below messages. Clicking opens ThreadPanel. ThreadPanel already exists and works (loads conversation, supports replying).
|
||||
|
||||
**Rationale**: The ThreadPanel already handles thread display and replying. The missing piece is the reply_count in the message list so users know threads exist. The "View thread" button exists in MessageList but only on hover — need to make it always visible when reply_count > 0.
|
||||
|
||||
## Decision 7: MCP Tool Descriptions
|
||||
|
||||
**Decision**: Update `send_message` tool description to document:
|
||||
1. `reply_to` parameter: "ID of the parent message to reply to. Creates a threaded reply. Always use this when responding to a message that is part of a thread."
|
||||
2. `attachments` parameter (new): "Array of attachment hashes to link to this message. Upload attachments first using the upload_attachment action."
|
||||
|
||||
**Rationale**: Agents need clear tool descriptions to know how to use threading and attachments. The current reply_to description is minimal.
|
||||
|
||||
## Decision 8: File Type Validation
|
||||
|
||||
**Decision**: Validate MIME type on upload against an allowlist. Supported: image/*, application/pdf, text/*. Reject everything else with a clear error message listing supported types.
|
||||
|
||||
**Rationale**: Security best practice — don't allow arbitrary file uploads. The existing MIME detection in `mime.go` already handles type detection; just need to add validation in the upload handler.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Feature Specification: Attachments & Threads Enhancement
|
||||
|
||||
**Feature Branch**: `009-attachments-threads`
|
||||
**Created**: 2026-03-17
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Add file attachment support to web UI and MCP tools, fix thread visibility issues, add admin CLI backup/restore for attachments"
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Max file size: 50 MB (already enforced in existing backend)
|
||||
- Supported attachment types: images (jpg, png, gif, webp, svg), PDFs, text files (txt, md, csv, json, xml, yaml, log)
|
||||
- Image thumbnails: 200x200px maximum CSS constraint, rendered client-side from original image (no server-side thumbnail generation)
|
||||
- Fullscreen image viewer: CSS overlay with close button and download link, dismissed by clicking outside or pressing Escape
|
||||
- Thread indicator: Badge on parent message showing reply count; clicking it opens the thread panel
|
||||
- `reply_to` column: nullable integer foreign key referencing `messages(id)` in a new migration
|
||||
- Attachment backup format: tar.gz archive of the content-addressable storage directory
|
||||
- MCP tool descriptions will be updated to clearly document `reply_to` and attachment parameters
|
||||
- Attachment upload in web UI uses multipart/form-data to the existing POST /api/attachments endpoint
|
||||
- Multiple attachments per message are supported (existing schema allows this via message_id FK)
|
||||
- Agents attach files by providing base64-encoded content via the existing `upload_attachment` MCP action, then referencing the hash when sending a message
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Attach Files from Web UI (Priority: P1)
|
||||
|
||||
A user composing a message in the web UI wants to attach one or more files (images, PDFs, text files) to their message. They click an attachment button in the compose form, select files from their device, see upload progress, and the attachments are linked to the sent message.
|
||||
|
||||
**Why this priority**: This is the core feature request. Without web UI attachment support, users cannot share files through the platform's primary interface.
|
||||
|
||||
**Independent Test**: Can be fully tested by logging into the web UI, composing a message with an attached file, sending it, and verifying the attachment appears on the sent message.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a logged-in user on a channel page, **When** they click the attachment button and select a 5MB PNG image, **Then** the file uploads successfully and appears as a preview in the compose area before sending.
|
||||
2. **Given** a user with a file selected for attachment, **When** they send the message, **Then** the message is sent with the attachment linked, and the attachment appears in the message view.
|
||||
3. **Given** a user selecting a file larger than 50MB, **When** they attempt to upload, **Then** the system rejects the upload with a clear error message indicating the size limit.
|
||||
4. **Given** a user selecting an unsupported file type (e.g., .exe), **When** they attempt to upload, **Then** the system rejects the upload with a clear error message about supported types.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - View Image Attachments with Thumbnails and Fullscreen (Priority: P1)
|
||||
|
||||
When viewing messages that have image attachments, users see a thumbnail preview inline with the message. Clicking the thumbnail opens a fullscreen overlay. Users can also download the attachment.
|
||||
|
||||
**Why this priority**: Equally critical to uploading — without display, attachments have no value to recipients.
|
||||
|
||||
**Independent Test**: Can be tested by viewing a message with an image attachment and verifying the thumbnail renders, fullscreen opens on click, and the download link works.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a message with a PNG image attachment, **When** the message is displayed, **Then** a thumbnail (max 200x200px) appears inline below the message body.
|
||||
2. **Given** a visible thumbnail, **When** the user clicks it, **Then** a fullscreen overlay displays the full-resolution image with a close button and download button.
|
||||
3. **Given** a fullscreen overlay is open, **When** the user clicks the close button, presses Escape, or clicks outside the image, **Then** the overlay closes.
|
||||
4. **Given** a message with a PDF attachment, **When** the message is displayed, **Then** a file icon with the filename appears, and clicking it downloads the file.
|
||||
5. **Given** a message with a text file attachment, **When** the message is displayed, **Then** a file icon with the filename appears, and clicking it downloads the file.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Thread Visibility and Reply Indicators (Priority: P1)
|
||||
|
||||
Messages that have thread replies must show a visible indicator with the reply count. Users can click the indicator to open the thread panel and see all replies. New replies in a thread must be visible.
|
||||
|
||||
**Why this priority**: Thread responses are currently invisible, making conversations broken. This is a critical usability fix.
|
||||
|
||||
**Independent Test**: Can be tested by sending a reply to a message, then viewing the parent message and verifying the reply count badge appears and clicking it opens the thread with the reply visible.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a message with 3 thread replies, **When** the message is displayed in the message list, **Then** a thread indicator shows "3 replies" below the message.
|
||||
2. **Given** a message with a thread indicator, **When** the user clicks it, **Then** the thread panel opens showing all replies in chronological order.
|
||||
3. **Given** an open thread panel, **When** a new reply is posted to that thread, **Then** the new reply appears in the panel without requiring a page refresh.
|
||||
4. **Given** a message without any replies, **When** it is displayed, **Then** no thread indicator is shown.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Agents Attach Files via MCP Tools (Priority: P2)
|
||||
|
||||
AI agents can attach files (images, PDFs, text) to messages they send via MCP tools. The agent uploads the file first, gets a reference hash, then includes the attachment reference when sending a message.
|
||||
|
||||
**Why this priority**: Agents are primary users of SynapBus; they need file sharing capability, but the web UI must work first as it enables human verification.
|
||||
|
||||
**Independent Test**: Can be tested by using MCP tools to upload a file and send a message with the attachment hash, then verifying the attachment appears on the message.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authenticated agent, **When** it calls `upload_attachment` with base64-encoded image content, **Then** the file is stored and a hash is returned.
|
||||
2. **Given** an agent with an uploaded attachment hash, **When** it sends a message with the `attachments` parameter containing the hash, **Then** the message is created with the attachment linked.
|
||||
3. **Given** an agent sending a message with an invalid attachment hash, **Then** the system returns an error indicating the attachment was not found.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Agents Reply in Threads via MCP (Priority: P2)
|
||||
|
||||
When an agent receives a message that is part of a thread (has a `reply_to` context), the agent's response must be sent as a reply in the same thread. MCP tool descriptions clearly document this behavior.
|
||||
|
||||
**Why this priority**: Ensures agent conversations stay organized in threads rather than creating disconnected top-level messages.
|
||||
|
||||
**Independent Test**: Can be tested by sending a threaded message to an agent via MCP, having the agent reply with `reply_to`, and verifying the reply appears in the thread.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an agent receiving a DM that is a thread reply, **When** the message metadata includes the thread context, **Then** the agent can see the `reply_to` field identifying the parent message.
|
||||
2. **Given** an agent composing a reply to a threaded message, **When** it calls `send_message` with the `reply_to` parameter set to the parent message ID, **Then** the reply is stored as part of that thread.
|
||||
3. **Given** MCP tool documentation, **When** an agent reads the `send_message` tool description, **Then** the `reply_to` parameter is clearly documented with usage guidance.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - Admin Backup and Restore Attachments (Priority: P3)
|
||||
|
||||
System administrators can backup all attachment files to a tar.gz archive and restore them from such an archive, using CLI commands. This enables disaster recovery and migration.
|
||||
|
||||
**Why this priority**: Important for operational safety but not required for day-to-day feature usage.
|
||||
|
||||
**Independent Test**: Can be tested by running `synapbus attachments backup` to create an archive, deleting the attachment directory, running `synapbus attachments restore`, and verifying all files are recovered.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a data directory with stored attachments, **When** the admin runs `synapbus attachments backup --output /path/to/backup.tar.gz`, **Then** a tar.gz archive is created containing all attachment files.
|
||||
2. **Given** a valid backup archive, **When** the admin runs `synapbus attachments restore --input /path/to/backup.tar.gz`, **Then** all attachment files are restored to the data directory.
|
||||
3. **Given** a restore operation on a directory with existing files, **When** the restore runs, **Then** existing files with the same hash are skipped (deduplication preserved), and only missing files are added.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a user uploads a file with the same content (same SHA-256 hash) as an existing attachment? The system deduplicates and reuses the existing stored file.
|
||||
- What happens when a message with attachments is deleted? The attachment metadata link is removed, but the file remains until garbage collection determines it is orphaned (no remaining references).
|
||||
- What happens when a thread's parent message is deleted? Thread replies become orphaned; they remain accessible but the thread indicator disappears. The reply_to FK should use SET NULL on delete.
|
||||
- What happens when an agent uploads a file exceeding 50MB via MCP? The upload is rejected with an error message indicating the size limit.
|
||||
- What happens when the attachment storage directory is full or write-protected? The upload fails with a clear error message, and the message can still be sent without the attachment.
|
||||
- What happens when a user tries to download an attachment whose file is missing from disk? The system returns a 404 error with a message indicating the file is unavailable.
|
||||
- What happens when deeply nested thread replies occur? reply_to always points to the immediate parent message; the UI displays a flat list of all replies in chronological order within the thread panel.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST allow users to attach files (images, PDFs, text files) when composing messages in the web UI
|
||||
- **FR-002**: System MUST enforce a maximum file size of 50 MB per attachment
|
||||
- **FR-003**: System MUST restrict uploads to supported file types: images (jpg, png, gif, webp, svg), PDFs, and text files (txt, md, csv, json, xml, yaml, log)
|
||||
- **FR-004**: System MUST display inline thumbnail previews (max 200x200px) for image attachments in message views
|
||||
- **FR-005**: System MUST provide a fullscreen overlay when a user clicks an image thumbnail, with close (click outside, Escape key, close button) and download functionality
|
||||
- **FR-006**: System MUST display non-image attachments (PDFs, text files) as file icons with filename and a download link
|
||||
- **FR-007**: System MUST store attachments in a content-addressable filesystem with SHA-256 deduplication, storing only metadata references in the database
|
||||
- **FR-008**: System MUST support the `reply_to` field on messages, storing a nullable reference to the parent message ID
|
||||
- **FR-009**: System MUST display a thread indicator (reply count badge) on messages that have one or more replies
|
||||
- **FR-010**: System MUST allow users to open a thread panel showing all replies when clicking the thread indicator
|
||||
- **FR-011**: System MUST allow agents to upload attachments via MCP tools using base64-encoded content
|
||||
- **FR-012**: System MUST allow agents to link uploaded attachments to messages sent via MCP tools
|
||||
- **FR-013**: System MUST allow agents to send threaded replies via MCP tools using the `reply_to` parameter
|
||||
- **FR-014**: MCP tool descriptions MUST clearly document the `reply_to` parameter and attachment workflow
|
||||
- **FR-015**: System MUST provide an admin CLI command to backup all attachment files to a tar.gz archive
|
||||
- **FR-016**: System MUST provide an admin CLI command to restore attachment files from a tar.gz archive with deduplication
|
||||
- **FR-017**: System MUST return the reply count for each message in API responses to enable thread indicators
|
||||
- **FR-018**: System MUST allow downloading attachments via a direct URL using the content hash
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Attachment**: A file stored in the content-addressable filesystem. Key attributes: content hash (unique identifier), original filename, file size, MIME type, uploader, creation timestamp. Related to messages via a foreign key.
|
||||
- **Message**: A communication unit between agents or in channels. Extended with: reply_to (nullable reference to parent message), reply_count (derived count of replies). Related to attachments (one-to-many).
|
||||
- **Thread**: A logical grouping of messages linked by reply_to references. Not a separate entity — derived from message relationships. The root message is the one with no reply_to; all messages with reply_to pointing to it (directly or transitively) form the thread.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can attach and send a file in under 10 seconds for files under 10 MB
|
||||
- **SC-002**: Image thumbnails render within 1 second of message display
|
||||
- **SC-003**: 100% of thread replies are visible when opening a thread panel (zero invisible replies)
|
||||
- **SC-004**: Agents can upload and attach files to messages in a single workflow (upload then send)
|
||||
- **SC-005**: Thread reply count is accurate and updates immediately when new replies are added
|
||||
- **SC-006**: Admin backup captures all stored attachment files; restore recovers 100% of backed-up files
|
||||
- **SC-007**: All attachment operations (upload, download, preview) work for all supported file types
|
||||
- **SC-008**: MCP tool descriptions are self-documenting — an agent can understand attachment and thread workflows from tool descriptions alone
|
||||
@@ -0,0 +1,159 @@
|
||||
# Tasks: Attachments & Threads Enhancement
|
||||
|
||||
**Input**: Design documents from `/specs/009-attachments-threads/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Included (explicitly requested in feature description)
|
||||
|
||||
**Organization**: Tasks grouped by user story for independent implementation and testing.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: No new project setup needed — extending existing codebase. Verify current state.
|
||||
|
||||
- [x] T001 Verify migration 007_threads.sql exists and is applied in internal/storage/schema/007_threads.sql
|
||||
- [x] T002 Run `make test` to confirm existing tests pass before changes
|
||||
|
||||
**Checkpoint**: Baseline verified — proceed with foundational changes
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Backend data model changes that ALL user stories depend on
|
||||
|
||||
**CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [x] T003 Add ReplyCount and Attachments fields to Message struct in internal/messaging/types.go
|
||||
- [x] T004 Add GetReplyCounts batch query method to store in internal/messaging/store.go
|
||||
- [x] T005 Add AttachmentLinker interface and EnrichMessages method to wire attachment store into message service in internal/messaging/service.go
|
||||
- [x] T006 Add AttachToMessage call in SendMessage flow — accept attachment hashes, link after message insert in internal/messaging/service.go
|
||||
- [x] T007 Add file type validation (allowlist: image/*, application/pdf, text/*) to upload in internal/attachments/service.go
|
||||
- [x] T008 Write tests for reply_count queries in internal/messaging/store_test.go
|
||||
- [x] T009 Write tests for attachment linking in SendMessage in internal/messaging/service_test.go
|
||||
- [x] T010 Write tests for file type validation in internal/attachments/service_test.go
|
||||
|
||||
**Checkpoint**: Foundation ready — message API returns reply_count and attachments, file type validation enforced
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Attach Files from Web UI (Priority: P1)
|
||||
|
||||
**Goal**: Users can attach files in the compose form and send messages with attachments
|
||||
|
||||
**Independent Test**: Log in to web UI, compose message with attached file, send, verify attachment appears
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T011 [US1] Accept `attachments` (array of hashes) in send message API request and link them after message creation in internal/api/messages_handler.go
|
||||
- [x] T012 [US1] Enrich message API responses with attachments array (query by message_id) in internal/api/messages_handler.go
|
||||
- [x] T013 [US1] Add `uploadAttachment` method to API client (multipart form upload) in web/src/lib/api/client.ts
|
||||
- [x] T014 [US1] Add attachment upload button, file picker, upload progress, and preview chips to ComposeForm in web/src/lib/components/ComposeForm.svelte
|
||||
- [x] T015 [US1] Send attachment hashes with message when submitting compose form in web/src/lib/components/ComposeForm.svelte
|
||||
|
||||
**Checkpoint**: Users can attach and send files from web UI
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — View Image Attachments with Thumbnails and Fullscreen (Priority: P1)
|
||||
|
||||
**Goal**: Messages display image thumbnails inline; click opens fullscreen; non-image attachments show file icon with download
|
||||
|
||||
**Independent Test**: View a message with image attachment, verify thumbnail renders, click for fullscreen, download works
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T016 [US2] Create AttachmentPreview.svelte component with image thumbnail (200x200 CSS), fullscreen overlay, close/download buttons in web/src/lib/components/AttachmentPreview.svelte
|
||||
- [x] T017 [US2] Integrate attachment display into MessageList.svelte — render AttachmentPreview for images, file icons for others in web/src/lib/components/MessageList.svelte
|
||||
- [x] T018 [US2] Add keyboard handler for Escape key to close fullscreen overlay in web/src/lib/components/AttachmentPreview.svelte
|
||||
- [x] T019 [US2] Add attachment display to ThreadPanel.svelte for thread messages in web/src/lib/components/ThreadPanel.svelte
|
||||
|
||||
**Checkpoint**: Attachment display fully functional in web UI
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Thread Visibility and Reply Indicators (Priority: P1)
|
||||
|
||||
**Goal**: Messages with replies show reply count badge; clicking opens thread panel; thread replies visible
|
||||
|
||||
**Independent Test**: Send reply to a message, verify parent shows "N replies" badge, click to open thread panel
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T020 [US3] Ensure all message handlers return reply_count in response JSON via EnrichMessages in internal/api/messages_handler.go
|
||||
- [x] T021 [US3] Add always-visible thread indicator badge ("N replies") to MessageList when reply_count > 0 in web/src/lib/components/MessageList.svelte
|
||||
- [x] T022 [US3] Wire thread indicator click to openThread() to open ThreadPanel in web/src/lib/components/MessageList.svelte
|
||||
|
||||
**Checkpoint**: Thread visibility fixed — users can see and navigate thread replies
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Agents Attach Files via MCP Tools (Priority: P2)
|
||||
|
||||
**Goal**: Agents upload files and link them to messages via MCP tools
|
||||
|
||||
**Independent Test**: Use MCP execute tool to upload_attachment, then send_message with attachments, verify linked
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T023 [US4] Add `attachments` parameter (comma-separated hashes) to send_message MCP tool schema in internal/mcp/tools_hybrid.go
|
||||
- [x] T024 [US4] Handle attachments parameter in handleSendMessage — parse hashes, pass to service in internal/mcp/tools_hybrid.go
|
||||
- [x] T025 [US4] Handle attachments in bridge.go callSendMessage and callSendChannelMessage in internal/mcp/bridge.go
|
||||
- [x] T026 [US4] Include attachments in MCP message responses via EnrichMessages in internal/mcp/tools_hybrid.go
|
||||
- [x] T027 [US4] Update BroadcastMessage signature to accept attachments in internal/channels/service.go
|
||||
|
||||
**Checkpoint**: Agents can attach files to messages via MCP
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — Agents Reply in Threads via MCP (Priority: P2)
|
||||
|
||||
**Goal**: MCP tool descriptions clearly document threading; agents can reliably use reply_to
|
||||
|
||||
**Independent Test**: Send threaded message via MCP, verify reply appears in thread, tool descriptions are clear
|
||||
|
||||
### Implementation for User Story 5
|
||||
|
||||
- [x] T028 [US5] Update send_message tool description to clearly document reply_to usage and threading behavior in internal/mcp/tools_hybrid.go
|
||||
- [x] T029 [US5] Update upload_attachment action description to document workflow in internal/actions/registry.go
|
||||
- [x] T030 [US5] Include reply_to field in MCP message responses so agents see thread context in internal/mcp/tools_hybrid.go
|
||||
|
||||
**Checkpoint**: MCP tools are self-documenting for threading and attachments
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: User Story 6 — Admin Backup and Restore Attachments (Priority: P3)
|
||||
|
||||
**Goal**: Admin CLI can backup attachments to tar.gz and restore from archive
|
||||
|
||||
**Independent Test**: Run backup, verify archive, restore to empty dir, verify files recovered
|
||||
|
||||
### Implementation for User Story 6
|
||||
|
||||
- [x] T031 [US6] Add `synapbus attachments backup --output <path>` subcommand using archive/tar + compress/gzip in cmd/synapbus/admin.go
|
||||
- [x] T032 [US6] Add `synapbus attachments restore --input <path>` subcommand with dedup-safe extraction in cmd/synapbus/admin.go
|
||||
|
||||
**Checkpoint**: Admin can backup and restore attachment files
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final integration, edge cases, build verification
|
||||
|
||||
- [x] T033 Run `go test ./...` to verify all Go tests pass (25 packages, 0 failures)
|
||||
- [x] T034 Run `make web` to build Svelte SPA and verify no build errors
|
||||
- [x] T035 Run `make build` to compile final binary (90MB arm64)
|
||||
- [x] T036 Run integration tests to verify no regressions (9 E2E tests pass)
|
||||
|
||||
---
|
||||
|
||||
## Completion Summary
|
||||
|
||||
All 36 tasks completed. All tests pass. Build successful.
|
||||
@@ -62,7 +62,7 @@ export const messages = {
|
||||
return request<{ messages: any[]; total: number }>('GET', `/api/messages${q ? '?' + q : ''}`);
|
||||
},
|
||||
get: (id: number) => request<any>('GET', `/api/messages/${id}`),
|
||||
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_id?: number; reply_to?: number }) =>
|
||||
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_id?: number; reply_to?: number; attachments?: string[] }) =>
|
||||
request<any>('POST', '/api/messages', body),
|
||||
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
|
||||
search: (q: string, opts?: { limit?: number; channel?: string; agent?: string; after?: string; before?: string }) => {
|
||||
@@ -232,4 +232,22 @@ export const profile = {
|
||||
request<{ message: string; user: { id: number; username: string; display_name: string; role: string } }>('PUT', '/api/auth/profile', body)
|
||||
};
|
||||
|
||||
// Attachments
|
||||
export const attachments = {
|
||||
upload: async (file: File): Promise<{hash: string, size: number, mime_type: string, original_filename: string}> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch('/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({error: 'Upload failed'}));
|
||||
throw new Error(err.error || err.detail || 'Upload failed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
type Attachment = {
|
||||
hash: string;
|
||||
original_filename: string;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
is_image: boolean;
|
||||
};
|
||||
|
||||
let { attachment }: { attachment: Attachment } = $props();
|
||||
|
||||
let showOverlay = $state(false);
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) {
|
||||
return (bytes / 1024).toFixed(1) + ' KB';
|
||||
}
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function openOverlay() {
|
||||
showOverlay = true;
|
||||
}
|
||||
|
||||
function closeOverlay() {
|
||||
showOverlay = false;
|
||||
}
|
||||
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
closeOverlay();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={showOverlay ? handleKeydown : undefined} />
|
||||
|
||||
{#if attachment.is_image}
|
||||
<!-- Image thumbnail -->
|
||||
<button
|
||||
class="block rounded-lg overflow-hidden border border-border hover:border-border-active transition-colors cursor-pointer bg-bg-tertiary"
|
||||
onclick={openOverlay}
|
||||
>
|
||||
<img
|
||||
src="/api/attachments/{attachment.hash}"
|
||||
alt={attachment.original_filename}
|
||||
class="max-w-[200px] max-h-[200px] object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Fullscreen overlay -->
|
||||
{#if showOverlay}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={handleOverlayClick}
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute top-4 right-4 p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors z-10"
|
||||
onclick={closeOverlay}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Full-resolution image -->
|
||||
<img
|
||||
src="/api/attachments/{attachment.hash}"
|
||||
alt={attachment.original_filename}
|
||||
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
|
||||
/>
|
||||
|
||||
<!-- Download button -->
|
||||
<div class="absolute bottom-6 left-1/2 -translate-x-1/2">
|
||||
<a
|
||||
href="/api/attachments/{attachment.hash}"
|
||||
download={attachment.original_filename}
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download {attachment.original_filename}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Non-image file -->
|
||||
<div class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-bg-tertiary hover:bg-bg-tertiary/80 transition-colors">
|
||||
<!-- File icon -->
|
||||
<svg class="w-5 h-5 text-text-secondary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<a
|
||||
href="/api/attachments/{attachment.hash}"
|
||||
download={attachment.original_filename}
|
||||
class="text-sm text-text-link hover:underline block truncate max-w-[200px]"
|
||||
>
|
||||
{attachment.original_filename}
|
||||
</a>
|
||||
<span class="text-[10px] text-text-secondary">{formatSize(attachment.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { messages as messagesApi, agents as agentsApi, channels as channelsApi } from '$lib/api/client';
|
||||
import { messages as messagesApi, agents as agentsApi, channels as channelsApi, attachments as attachmentsApi } from '$lib/api/client';
|
||||
|
||||
let { onSent = () => {} }: { onSent?: () => void } = $props();
|
||||
|
||||
@@ -12,6 +12,49 @@
|
||||
let error = $state('');
|
||||
let showOptions = $state(false);
|
||||
|
||||
// Attachment state
|
||||
type UploadedAttachment = { hash: string; original_filename: string; size: number; mime_type: string };
|
||||
let uploadedAttachments = $state<UploadedAttachment[]>([]);
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state('');
|
||||
let fileInputEl: HTMLInputElement | undefined = $state(undefined);
|
||||
|
||||
const ACCEPTED_FILES = '.jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.txt,.md,.csv,.json,.xml,.yaml,.yml,.log';
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) {
|
||||
return (bytes / 1024).toFixed(1) + ' KB';
|
||||
}
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInputEl?.click();
|
||||
}
|
||||
|
||||
async function handleFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
uploading = true;
|
||||
uploadError = '';
|
||||
try {
|
||||
const result = await attachmentsApi.upload(file);
|
||||
uploadedAttachments = [...uploadedAttachments, result];
|
||||
} catch (err: any) {
|
||||
uploadError = err.message || 'Upload failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
// Reset file input so same file can be re-selected
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(hash: string) {
|
||||
uploadedAttachments = uploadedAttachments.filter(a => a.hash !== hash);
|
||||
}
|
||||
|
||||
// Autocomplete state
|
||||
let agentList = $state<any[]>([]);
|
||||
let channelList = $state<any[]>([]);
|
||||
@@ -102,13 +145,16 @@
|
||||
body: body.trim(),
|
||||
priority,
|
||||
subject: subject.trim() || undefined,
|
||||
channel_id: channelId
|
||||
channel_id: channelId,
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments.map(a => a.hash) : undefined
|
||||
});
|
||||
to = '';
|
||||
body = '';
|
||||
priority = 5;
|
||||
subject = '';
|
||||
channelId = undefined;
|
||||
uploadedAttachments = [];
|
||||
uploadError = '';
|
||||
if (textareaEl) {
|
||||
textareaEl.style.height = '72px';
|
||||
textareaEl.style.overflowY = 'hidden';
|
||||
@@ -211,14 +257,71 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
accept={ACCEPTED_FILES}
|
||||
class="hidden"
|
||||
onchange={handleFileSelected}
|
||||
/>
|
||||
|
||||
<!-- Attachment list -->
|
||||
{#if uploadedAttachments.length > 0 || uploading || uploadError}
|
||||
<div class="px-4 py-2 border-t border-border space-y-1">
|
||||
{#if uploadError}
|
||||
<div class="text-xs text-accent-red">{uploadError}</div>
|
||||
{/if}
|
||||
{#if uploading}
|
||||
<div class="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Uploading...
|
||||
</div>
|
||||
{/if}
|
||||
{#each uploadedAttachments as att (att.hash)}
|
||||
<div class="flex items-center gap-2 text-xs text-text-primary bg-bg-tertiary rounded px-2 py-1">
|
||||
<svg class="w-3.5 h-3.5 text-text-secondary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
|
||||
</svg>
|
||||
<span class="truncate flex-1">{att.original_filename}</span>
|
||||
<span class="text-text-secondary flex-shrink-0">{formatFileSize(att.size)}</span>
|
||||
<button
|
||||
class="p-0.5 rounded hover:bg-bg-secondary text-text-secondary hover:text-accent-red transition-colors flex-shrink-0"
|
||||
onclick={() => removeAttachment(att.hash)}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="flex items-center justify-between px-3 py-2 border-t border-border">
|
||||
<button
|
||||
class="text-xs text-text-secondary hover:text-text-primary transition-colors px-2 py-1 rounded hover:bg-bg-tertiary"
|
||||
onclick={() => (showOptions = !showOptions)}
|
||||
>
|
||||
{showOptions ? 'Hide options' : 'Options'}
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="text-xs text-text-secondary hover:text-text-primary transition-colors px-2 py-1 rounded hover:bg-bg-tertiary"
|
||||
onclick={() => (showOptions = !showOptions)}
|
||||
>
|
||||
{showOptions ? 'Hide options' : 'Options'}
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors disabled:opacity-50"
|
||||
onclick={triggerFileInput}
|
||||
disabled={uploading}
|
||||
title="Attach file"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="px-4 py-1.5 bg-accent-green rounded text-xs font-semibold text-white hover:brightness-110 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
disabled={sending || !body.trim() || !to.trim()}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { openThread } from '$lib/stores/thread';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
|
||||
type Attachment = {
|
||||
hash: string;
|
||||
original_filename: string;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
is_image: boolean;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
id: number;
|
||||
@@ -12,6 +21,7 @@
|
||||
status: string;
|
||||
created_at: string;
|
||||
reply_count?: number;
|
||||
attachments?: Attachment[];
|
||||
};
|
||||
|
||||
let { messages = [], showConversationLink = false, agentTypes = {} }: { messages: Message[]; showConversationLink?: boolean; agentTypes?: Record<string, string> } = $props();
|
||||
@@ -108,8 +118,30 @@
|
||||
</div>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} truncate={300} /></div>
|
||||
|
||||
<!-- Thread link -->
|
||||
{#if showConversationLink && msg.conversation_id}
|
||||
<!-- Attachments -->
|
||||
{#if msg.attachments && msg.attachments.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each msg.attachments as att (att.hash)}
|
||||
<AttachmentPreview attachment={att} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reply count badge -->
|
||||
{#if msg.reply_count && msg.reply_count > 0}
|
||||
<button
|
||||
class="mt-1.5 flex items-center gap-1 text-xs text-text-link hover:underline"
|
||||
onclick={(e) => { e.stopPropagation(); openThread(msg.id, msg.conversation_id, msg.from_agent); }}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
{msg.reply_count === 1 ? '1 reply' : `${msg.reply_count} replies`}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Thread link (conversation view) -->
|
||||
{#if showConversationLink && msg.conversation_id && (!msg.reply_count || msg.reply_count === 0)}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-text-link hover:underline"
|
||||
onclick={(e) => { e.stopPropagation(); openThread(msg.id, msg.conversation_id, msg.from_agent); }}
|
||||
@@ -117,7 +149,7 @@
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
{msg.reply_count ? `${msg.reply_count} replies` : 'View thread'}
|
||||
View thread
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { activeThread, closeThread } from '$lib/stores/thread';
|
||||
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
|
||||
let conversation = $state<any>(null);
|
||||
let threadMessages = $state<any[]>([]);
|
||||
@@ -164,6 +165,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
{#if msg.attachments && msg.attachments.length > 0}
|
||||
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{#each msg.attachments as att (att.hash)}
|
||||
<AttachmentPreview attachment={att} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { channels as channelsApi, messages as messagesApi, agents as agentsApi } from '$lib/api/client';
|
||||
import { channels as channelsApi, messages as messagesApi, agents as agentsApi, attachments as attachmentsApi } from '$lib/api/client';
|
||||
import { openThread, closeThread } from '$lib/stores/thread';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
||||
|
||||
let channel = $state<any>(null);
|
||||
let members = $state<any[]>([]);
|
||||
@@ -21,6 +22,39 @@
|
||||
let sending = $state(false);
|
||||
let sendError = $state('');
|
||||
|
||||
// Attachment state
|
||||
let uploadedAttachments = $state<{hash: string, name: string, size: number}[]>([]);
|
||||
let uploading = $state(false);
|
||||
let fileInputEl: HTMLInputElement;
|
||||
|
||||
function triggerFileInput() { fileInputEl?.click(); }
|
||||
|
||||
async function handleFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
uploading = true;
|
||||
try {
|
||||
const result = await attachmentsApi.upload(file);
|
||||
uploadedAttachments = [...uploadedAttachments, { hash: result.hash, name: result.original_filename, size: result.size }];
|
||||
} catch (err: any) {
|
||||
sendError = err.message || 'Upload failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(hash: string) {
|
||||
uploadedAttachments = uploadedAttachments.filter(a => a.hash !== hash);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// Mark-as-read timer
|
||||
let markReadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -132,15 +166,17 @@
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!body.trim()) return;
|
||||
if (!body.trim() && uploadedAttachments.length === 0) return;
|
||||
sending = true;
|
||||
sendError = '';
|
||||
try {
|
||||
await messagesApi.send({
|
||||
body: body.trim(),
|
||||
channel_id: channel.id
|
||||
body: body.trim() || '(attachment)',
|
||||
channel_id: channel.id,
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments.map(a => a.hash) : undefined
|
||||
});
|
||||
body = '';
|
||||
uploadedAttachments = [];
|
||||
await loadMessages();
|
||||
} catch (err: any) {
|
||||
sendError = err.message || 'Failed to send message';
|
||||
@@ -270,6 +306,13 @@
|
||||
<span class="text-xs text-text-secondary">{formatTime(msg.created_at)}</span>
|
||||
</div>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
{#if msg.attachments?.length > 0}
|
||||
<div class="flex flex-wrap gap-2 mt-1.5">
|
||||
{#each msg.attachments as att}
|
||||
<AttachmentPreview attachment={att} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.reply_count > 0}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
|
||||
@@ -310,6 +353,30 @@
|
||||
Register an agent to send messages
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.txt,.md,.csv,.json,.xml,.yaml,.yml,.log"
|
||||
bind:this={fileInputEl}
|
||||
onchange={handleFileSelected}
|
||||
/>
|
||||
<!-- Attachment preview chips -->
|
||||
{#if uploadedAttachments.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5 mb-1.5">
|
||||
{#each uploadedAttachments as att}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-bg-tertiary border border-border rounded text-xs text-text-primary">
|
||||
<svg class="w-3 h-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" /></svg>
|
||||
{att.name}
|
||||
<span class="text-text-secondary">({formatFileSize(att.size)})</span>
|
||||
<button class="ml-0.5 text-text-secondary hover:text-accent-red" onclick={() => removeAttachment(att.hash)} title="Remove">×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if uploading}
|
||||
<div class="mb-1.5 text-xs text-text-secondary">Uploading...</div>
|
||||
{/if}
|
||||
<div class="flex items-end gap-2 bg-bg-tertiary rounded-lg border border-border focus-within:border-border-active transition-colors">
|
||||
<textarea
|
||||
placeholder="Message #{channelName}..."
|
||||
@@ -318,9 +385,19 @@
|
||||
rows="1"
|
||||
onkeydown={handleKeydown}
|
||||
></textarea>
|
||||
<button
|
||||
class="p-2 mb-1 rounded-md text-text-secondary hover:text-text-primary hover:bg-bg-secondary transition-all"
|
||||
onclick={triggerFileInput}
|
||||
title="Attach file"
|
||||
type="button"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 mr-1 mb-1 rounded-md bg-accent-green text-white hover:brightness-110 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={sending || !body.trim()}
|
||||
disabled={sending || (!body.trim() && uploadedAttachments.length === 0)}
|
||||
onclick={handleSend}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
|
||||
Reference in New Issue
Block a user