Merge pull request #76 from dj-oyu/feat/send-file-url-download
feat: send_file tool supports HTTP(S) URL download
This commit is contained in:
commit
2716b47f9a
3 changed files with 325 additions and 6 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue