diff --git a/pkg/agent/loop_session.go b/pkg/agent/loop_session.go index d3ee6eded..ffd9be78d 100644 --- a/pkg/agent/loop_session.go +++ b/pkg/agent/loop_session.go @@ -9,6 +9,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" ) // sessionSemaphore is a per-session mutex using a buffered channel. @@ -36,6 +37,7 @@ func (al *AgentLoop) gcLoop() { al.gcSessionLocks() al.pruneMediaCache() + tools.CleanupTempFiles() case <-al.done: diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go index a67bd4210..6e737ce5f 100644 --- a/pkg/tools/send_file.go +++ b/pkg/tools/send_file.go @@ -2,12 +2,17 @@ package tools import ( "context" + "crypto/rand" "fmt" + "io" "mime" + "net/http" + "net/url" "os" "path/filepath" "regexp" "strings" + "time" "github.com/h2non/filetype" @@ -15,6 +20,12 @@ import ( "github.com/sipeed/picoclaw/pkg/media" ) +const ( + sendFileTempDir = "/tmp/picoclaw-sendfile" + sendFileTempMaxAge = 1 * time.Hour + sendFileDownloadLimit = 50 * 1024 * 1024 // 50 MB download limit +) + // SendFileTool allows the LLM to send a local file (image, document, etc.) // to the user on the current chat channel via the MediaStore pipeline. type SendFileTool struct { @@ -53,7 +64,7 @@ func NewSendFileTool( func (t *SendFileTool) Name() string { return "send_file" } func (t *SendFileTool) Description() string { - return "Send a local file (image, document, etc.) to the user on the current chat channel." + return "Send a file to the user on the current chat channel. Accepts a local file path or an HTTP(S) URL (the URL will be downloaded automatically)." } func (t *SendFileTool) Parameters() map[string]any { @@ -62,7 +73,7 @@ func (t *SendFileTool) Parameters() map[string]any { "properties": map[string]any{ "path": map[string]any{ "type": "string", - "description": "Path to the local file. Relative paths are resolved from workspace.", + "description": "Path to a local file or an HTTP(S) URL. Relative paths are resolved from workspace. URLs are downloaded automatically.", }, "filename": map[string]any{ "type": "string", @@ -105,9 +116,23 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("media store not configured") } - resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths) - if err != nil { - return ErrorResult(fmt.Sprintf("invalid path: %v", err)) + // Handle URL downloads + var resolved string + var tempFile string // non-empty when we downloaded a URL + if isHTTPURL(path) { + var err error + resolved, err = downloadToTemp(ctx, path) + if err != nil { + return ErrorResult(fmt.Sprintf("download failed: %v", err)) + } + tempFile = resolved + defer os.Remove(tempFile) + } else { + var err error + resolved, err = validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid path: %v", err)) + } } info, err := os.Stat(resolved) @@ -126,7 +151,11 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe filename, _ := args["filename"].(string) if filename == "" { - filename = filepath.Base(resolved) + if tempFile != "" { + filename = filenameFromURL(path) + } else { + filename = filepath.Base(resolved) + } } mediaType := detectMediaType(resolved) @@ -144,6 +173,106 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref}) } +// isHTTPURL returns true if the path looks like an HTTP(S) URL. +func isHTTPURL(path string) bool { + return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") +} + +// downloadToTemp downloads a URL to a temporary file under sendFileTempDir. +// The caller is responsible for removing the returned file. +func downloadToTemp(ctx context.Context, rawURL string) (string, error) { + if err := os.MkdirAll(sendFileTempDir, 0o700); err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("HTTP GET: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status) + } + + // Generate a random temp filename to avoid collisions + var randBytes [8]byte + if _, err := rand.Read(randBytes[:]); err != nil { + return "", err + } + ext := extFromURL(rawURL) + tmpPath := filepath.Join(sendFileTempDir, fmt.Sprintf("dl_%x%s", randBytes, ext)) + + f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + + n, copyErr := io.Copy(f, io.LimitReader(resp.Body, sendFileDownloadLimit+1)) + if closeErr := f.Close(); closeErr != nil && copyErr == nil { + copyErr = closeErr + } + if copyErr != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("write temp file: %w", copyErr) + } + if n > sendFileDownloadLimit { + os.Remove(tmpPath) + return "", fmt.Errorf("download exceeds %d byte limit", sendFileDownloadLimit) + } + + return tmpPath, nil +} + +// extFromURL extracts a file extension from a URL path, e.g. ".jpg". +func extFromURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return filepath.Ext(u.Path) +} + +// filenameFromURL derives a display filename from a URL. +func filenameFromURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "download" + } + base := filepath.Base(u.Path) + if base == "" || base == "." || base == "/" { + return "download" + } + return base +} + +// CleanupTempFiles removes old temp files from the sendfile temp directory. +// Files older than sendFileTempMaxAge are deleted. +func CleanupTempFiles() { + entries, err := os.ReadDir(sendFileTempDir) + if err != nil { + return + } + cutoff := time.Now().Add(-sendFileTempMaxAge) + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + os.Remove(filepath.Join(sendFileTempDir, e.Name())) + } + } +} + // detectMediaType determines the MIME type of a file. // Uses magic-bytes detection (h2non/filetype) first, then falls back to // extension-based lookup via mime.TypeByExtension. diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go index 6daaab31c..9ab5fc794 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/send_file_test.go @@ -2,11 +2,15 @@ package tools import ( "context" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" + "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" @@ -167,6 +171,190 @@ func TestSendFileTool_AllowsWhitelistedMediaTempPath(t *testing.T) { } } +func TestSendFileTool_URLDownload(t *testing.T) { + // Start a test HTTP server serving a fake image + fakeImage := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.Write(fakeImage) + })) + defer srv.Close() + + store := media.NewFileMediaStore() + tool := NewSendFileTool(t.TempDir(), false, 0, store) + tool.SetContext("telegram", "chat123") + + result := tool.Execute(context.Background(), map[string]any{ + "path": srv.URL + "/photos/42.png", + "filename": "cat.png", + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } +} + +func TestSendFileTool_URLDownloadDefaultFilename(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("data")) + })) + defer srv.Close() + + store := media.NewFileMediaStore() + tool := NewSendFileTool(t.TempDir(), false, 0, store) + tool.SetContext("telegram", "chat123") + + result := tool.Execute(context.Background(), map[string]any{ + "path": srv.URL + "/mcp/photos/42", + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + // filename should be derived from URL path: "42" + if !strings.Contains(result.ForLLM, `"42"`) { + t.Errorf("expected filename '42' in result, got %q", result.ForLLM) + } +} + +func TestSendFileTool_URLDownloadHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + store := media.NewFileMediaStore() + tool := NewSendFileTool(t.TempDir(), false, 0, store) + tool.SetContext("telegram", "chat123") + + result := tool.Execute(context.Background(), map[string]any{ + "path": srv.URL + "/missing.jpg", + }) + if !result.IsError { + t.Fatal("expected error for HTTP 404") + } + if !strings.Contains(result.ForLLM, "404") { + t.Errorf("expected 404 in error, got %q", result.ForLLM) + } +} + +func TestSendFileTool_URLTempFileCleanedUp(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("data")) + })) + defer srv.Close() + + store := media.NewFileMediaStore() + tool := NewSendFileTool(t.TempDir(), false, 0, store) + tool.SetContext("telegram", "chat123") + + // Execute should download and clean up temp file + tool.Execute(context.Background(), map[string]any{ + "path": srv.URL + "/photo.jpg", + }) + + // Verify no temp files remain with dl_ prefix in the temp dir + entries, _ := os.ReadDir(sendFileTempDir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), "dl_") { + info, _ := e.Info() + // Only flag files created very recently (within this test) + if info != nil && time.Since(info.ModTime()) < 5*time.Second { + t.Errorf("temp file not cleaned up: %s", e.Name()) + } + } + } +} + +func TestCleanupTempFiles(t *testing.T) { + if err := os.MkdirAll(sendFileTempDir, 0o700); err != nil { + t.Fatal(err) + } + + // Create an "old" temp file + oldFile := filepath.Join(sendFileTempDir, "dl_test_old.tmp") + if err := os.WriteFile(oldFile, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + // Set mtime to 2 hours ago + past := time.Now().Add(-2 * time.Hour) + os.Chtimes(oldFile, past, past) + + // Create a "new" temp file + newFile := filepath.Join(sendFileTempDir, "dl_test_new.tmp") + if err := os.WriteFile(newFile, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Remove(newFile) }) + + CleanupTempFiles() + + if _, err := os.Stat(oldFile); !os.IsNotExist(err) { + t.Error("expected old temp file to be deleted") + } + if _, err := os.Stat(newFile); err != nil { + t.Error("expected new temp file to remain") + } +} + +func TestFilenameFromURL(t *testing.T) { + tests := []struct { + url string + want string + }{ + {"https://example.com/photos/cat.jpg", "cat.jpg"}, + {"https://example.com/mcp/photos/42", "42"}, + {"https://example.com/", "download"}, + {"https://example.com", "download"}, + } + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + got := filenameFromURL(tt.url) + if got != tt.want { + t.Errorf("filenameFromURL(%q) = %q, want %q", tt.url, got, tt.want) + } + }) + } +} + +func TestIsHTTPURL(t *testing.T) { + if !isHTTPURL("https://example.com/photo.jpg") { + t.Error("expected true for https URL") + } + if !isHTTPURL("http://localhost:8080/file") { + t.Error("expected true for http URL") + } + if isHTTPURL("/tmp/file.jpg") { + t.Error("expected false for local path") + } + if isHTTPURL("file:///tmp/file.jpg") { + t.Error("expected false for file:// URL") + } +} + +func TestSendFileTool_URLDownloadTooLarge(t *testing.T) { + // maxFileSize を小さくして、ダウンロード自体は成功するがファイルサイズチェックで弾かれるケース + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(make([]byte, 1024)) + })) + defer srv.Close() + + store := media.NewFileMediaStore() + tool := NewSendFileTool(t.TempDir(), false, 512, store) // 512 byte limit + tool.SetContext("telegram", "chat123") + + result := tool.Execute(context.Background(), map[string]any{ + "path": fmt.Sprintf("%s/big.bin", srv.URL), + }) + if !result.IsError { + t.Fatal("expected error for oversized downloaded file") + } + if !strings.Contains(result.ForLLM, "too large") { + t.Errorf("expected 'too large' in error, got %q", result.ForLLM) + } +} + func TestDetectMediaType_MagicBytes(t *testing.T) { dir := t.TempDir()