mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-13 12:10:40 +00:00
0f2737ce53
- Add persistent media storage (internal/media/) replacing temp file deletion
- Add MediaRef type for lightweight media references in session messages
- Refactor media pipeline to use bus.MediaFile{Path, MimeType} across all channels
- Add read_document builtin tool for PDF/DOCX/XLSX analysis via Gemini native API
- Move image sanitization from Telegram to shared agent/media layer
- Add media reload for multi-turn conversations (images from last 5 messages)
- Add reply-to-message media resolution for Telegram (re-download on reply)
- Add media inventory to compaction summary to preserve awareness after truncation
- Fix coreToolSummaries for read_image, read_document, create_image tools
- Add real-time trace update events via WebSocket broadcast
- Improve trace detail UI with media refs and tool result display
140 lines
3.9 KiB
Go
140 lines
3.9 KiB
Go
package media
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Store provides persistent media file storage scoped by session.
|
|
// Files are organized as: {baseDir}/{sessionHash}/{uuid}.{ext}
|
|
type Store struct {
|
|
baseDir string
|
|
}
|
|
|
|
// NewStore creates a media store rooted at baseDir.
|
|
// The directory is created if it doesn't exist.
|
|
func NewStore(baseDir string) (*Store, error) {
|
|
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("media: create base dir: %w", err)
|
|
}
|
|
return &Store{baseDir: baseDir}, nil
|
|
}
|
|
|
|
// SaveFile moves or copies a file to persistent storage.
|
|
// Returns the unique media ID and the destination path.
|
|
func (s *Store) SaveFile(sessionKey, srcPath, mime string) (id string, dstPath string, err error) {
|
|
dir := s.sessionDir(sessionKey)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return "", "", fmt.Errorf("media: create session dir: %w", err)
|
|
}
|
|
|
|
mediaID := uuid.New().String()
|
|
ext := extFromMime(mime)
|
|
if ext == "" {
|
|
ext = filepath.Ext(srcPath)
|
|
}
|
|
dstPath = filepath.Join(dir, mediaID+ext)
|
|
|
|
// Try rename first (fast, same filesystem).
|
|
if err := os.Rename(srcPath, dstPath); err == nil {
|
|
return mediaID, dstPath, nil
|
|
}
|
|
|
|
// Fallback: copy + remove source.
|
|
if err := copyFile(srcPath, dstPath); err != nil {
|
|
return "", "", fmt.Errorf("media: copy file: %w", err)
|
|
}
|
|
_ = os.Remove(srcPath) // best-effort cleanup of source
|
|
return mediaID, dstPath, nil
|
|
}
|
|
|
|
// LoadPath returns the filesystem path for a media ID.
|
|
// Returns an error if the file doesn't exist.
|
|
func (s *Store) LoadPath(id string) (string, error) {
|
|
// Media files are stored as {sessionHash}/{id}.{ext}.
|
|
// Since we don't know the session hash, glob for the ID across all session dirs.
|
|
matches, err := filepath.Glob(filepath.Join(s.baseDir, "*", id+".*"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("media: glob for %s: %w", id, err)
|
|
}
|
|
if len(matches) == 0 {
|
|
return "", fmt.Errorf("media: file not found: %s", id)
|
|
}
|
|
return matches[0], nil
|
|
}
|
|
|
|
// DeleteSession removes all media files for a session.
|
|
func (s *Store) DeleteSession(sessionKey string) error {
|
|
dir := s.sessionDir(sessionKey)
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
slog.Warn("media: failed to delete session dir", "dir", dir, "error", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sessionDir returns the directory path for a session's media files.
|
|
// Uses first 12 chars of SHA-256 hash of sessionKey for filesystem safety.
|
|
func (s *Store) sessionDir(sessionKey string) string {
|
|
h := sha256.Sum256([]byte(sessionKey))
|
|
hash := fmt.Sprintf("%x", h[:6]) // 12 hex chars
|
|
return filepath.Join(s.baseDir, hash)
|
|
}
|
|
|
|
// extFromMime returns a file extension (with dot) for a MIME type.
|
|
func extFromMime(mime string) string {
|
|
switch {
|
|
case strings.HasPrefix(mime, "image/jpeg"):
|
|
return ".jpg"
|
|
case strings.HasPrefix(mime, "image/png"):
|
|
return ".png"
|
|
case strings.HasPrefix(mime, "image/gif"):
|
|
return ".gif"
|
|
case strings.HasPrefix(mime, "image/webp"):
|
|
return ".webp"
|
|
case strings.HasPrefix(mime, "video/mp4"):
|
|
return ".mp4"
|
|
case strings.HasPrefix(mime, "audio/ogg"), strings.HasPrefix(mime, "audio/opus"):
|
|
return ".ogg"
|
|
case strings.HasPrefix(mime, "audio/mpeg"):
|
|
return ".mp3"
|
|
case strings.HasPrefix(mime, "audio/wav"):
|
|
return ".wav"
|
|
case strings.HasPrefix(mime, "application/pdf"):
|
|
return ".pdf"
|
|
case mime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
|
return ".docx"
|
|
case mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
|
return ".xlsx"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// copyFile copies src to dst using buffered I/O.
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|