From a56d97f233c2fe18e82d0b25465733b48821f437 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Sun, 22 Feb 2026 14:19:59 +0900 Subject: [PATCH 1/5] fix: replace premature media file cleanup with background MediaCleaner Channel handlers delete downloaded media files via defer os.Remove() before the async agent loop consumer can read them, causing silent failures in voice/image/document processing. Remove the immediate defer cleanup from telegram, line, slack, and onebot handlers. Introduce a background MediaCleaner goroutine that periodically removes files older than 30 minutes from the temp media directory, preventing unbounded accumulation. discord.go is unchanged because it processes files synchronously. Closes #619 Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_gateway.go | 5 +++ pkg/channels/line.go | 22 ++-------- pkg/channels/onebot.go | 20 --------- pkg/channels/slack.go | 17 +------- pkg/channels/telegram.go | 25 ++--------- pkg/utils/media.go | 86 ++++++++++++++++++++++++++++++++++++- pkg/utils/media_test.go | 66 ++++++++++++++++++++++++++++ 7 files changed, 164 insertions(+), 77 deletions(-) create mode 100644 pkg/utils/media_test.go diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 28ef76ad3..d299fb2d3 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -25,6 +25,7 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/voice" ) @@ -192,6 +193,9 @@ func gatewayCmd() { fmt.Println("✓ Device event service started") } + mediaCleaner := utils.NewMediaCleaner() + mediaCleaner.Start() + if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) } @@ -216,6 +220,7 @@ func gatewayCmd() { deviceService.Stop() heartbeatService.Stop() cronService.Stop() + mediaCleaner.Stop() agentLoop.Stop() channelManager.StopAll(ctx) fmt.Println("✓ Gateway stopped") diff --git a/pkg/channels/line.go b/pkg/channels/line.go index 44134996f..009be68a1 100644 --- a/pkg/channels/line.go +++ b/pkg/channels/line.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "net/http" - "os" "strings" "sync" "time" @@ -307,18 +306,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { var content string var mediaPaths []string - localFiles := []string{} - - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) - } - } - }() switch msg.Type { case "text": @@ -330,22 +317,19 @@ func (c *LINEChannel) processEvent(event lineEvent) { case "image": localPath := c.downloadContent(msg.ID, "image.jpg") if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, localPath) content = "[image]" } case "audio": localPath := c.downloadContent(msg.ID, "audio.m4a") if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, localPath) content = "[audio]" } case "video": localPath := c.downloadContent(msg.ID, "video.mp4") if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, localPath) content = "[video]" } case "file": diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index cee8ad9d3..6baacfc2a 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "strconv" "strings" "sync" @@ -571,7 +570,6 @@ type parseMessageResult struct { Text string IsBotMentioned bool Media []string - LocalFiles []string ReplyTo string } @@ -603,7 +601,6 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) mentioned := false selfIDStr := strconv.FormatInt(selfID, 10) var media []string - var localFiles []string var replyTo string for _, seg := range segments { @@ -642,7 +639,6 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) }) if localPath != "" { media = append(media, localPath) - localFiles = append(localFiles, localPath) textParts = append(textParts, fmt.Sprintf("[%s]", segType)) } } @@ -656,7 +652,6 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) LoggerPrefix: "onebot", }) if localPath != "" { - localFiles = append(localFiles, localPath) if c.transcriber != nil && c.transcriber.IsAvailable() { tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second) result, err := c.transcriber.Transcribe(tctx, localPath) @@ -703,7 +698,6 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) Text: strings.TrimSpace(strings.Join(textParts, "")), IsBotMentioned: mentioned, Media: media, - LocalFiles: localFiles, ReplyTo: replyTo, } } @@ -824,20 +818,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { } } - // Clean up temp files when done - if len(parsed.LocalFiles) > 0 { - defer func() { - for _, f := range parsed.LocalFiles { - if err := os.Remove(f); err != nil { - logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{ - "path": f, - "error": err.Error(), - }) - } - } - }() - } - if c.isDuplicate(messageID) { logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{ "message_id": messageID, diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index f7359cd6d..d3e400e24 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -3,7 +3,6 @@ package channels import ( "context" "fmt" - "os" "strings" "sync" "time" @@ -232,19 +231,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { content = c.stripBotMention(content) var mediaPaths []string - localFiles := []string{} // 跟踪需要清理的本地文件 - - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) - } - } - }() if ev.Message != nil && len(ev.Message.Files) > 0 { for _, file := range ev.Message.Files { @@ -252,8 +238,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { if localPath == "" { continue } - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, localPath) if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() { ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index a0a1c8d0a..8681f7295 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -221,19 +221,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content := "" mediaPaths := []string{} - localFiles := []string{} // 跟踪需要清理的本地文件 - - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) - } - } - }() if message.Text != "" { content += message.Text @@ -250,8 +237,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes photo := message.Photo[len(message.Photo)-1] photoPath := c.downloadPhoto(ctx, photo.FileID) if photoPath != "" { - localFiles = append(localFiles, photoPath) - mediaPaths = append(mediaPaths, photoPath) + mediaPaths = append(mediaPaths, photoPath) if content != "" { content += "\n" } @@ -262,8 +248,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Voice != nil { voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") if voicePath != "" { - localFiles = append(localFiles, voicePath) - mediaPaths = append(mediaPaths, voicePath) + mediaPaths = append(mediaPaths, voicePath) transcribedText := "" if c.transcriber != nil && c.transcriber.IsAvailable() { @@ -297,8 +282,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Audio != nil { audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") if audioPath != "" { - localFiles = append(localFiles, audioPath) - mediaPaths = append(mediaPaths, audioPath) + mediaPaths = append(mediaPaths, audioPath) if content != "" { content += "\n" } @@ -309,8 +293,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Document != nil { docPath := c.downloadFile(ctx, message.Document.FileID, "") if docPath != "" { - localFiles = append(localFiles, docPath) - mediaPaths = append(mediaPaths, docPath) + mediaPaths = append(mediaPaths, docPath) if content != "" { content += "\n" } diff --git a/pkg/utils/media.go b/pkg/utils/media.go index a34889fb8..79bc7217d 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/google/uuid" @@ -13,6 +14,9 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" ) +// MediaDir is the subdirectory name under os.TempDir() where downloaded media files are stored. +const MediaDir = "picoclaw_media" + // IsAudioFile checks if a file is an audio file based on its filename extension and content type. func IsAudioFile(filename, contentType string) bool { audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"} @@ -65,7 +69,7 @@ func DownloadFile(url, filename string, opts DownloadOptions) string { opts.LoggerPrefix = "utils" } - mediaDir := filepath.Join(os.TempDir(), "picoclaw_media") + mediaDir := filepath.Join(os.TempDir(), MediaDir) if err := os.MkdirAll(mediaDir, 0o700); err != nil { logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{ "error": err.Error(), @@ -141,3 +145,83 @@ func DownloadFileSimple(url, filename string) string { LoggerPrefix: "media", }) } + +// MediaCleaner periodically removes old files from the media temp directory. +type MediaCleaner struct { + interval time.Duration + maxAge time.Duration + stop chan struct{} + once sync.Once +} + +// NewMediaCleaner creates a new MediaCleaner with default settings +// (scan every 5 minutes, remove files older than 30 minutes). +func NewMediaCleaner() *MediaCleaner { + return &MediaCleaner{ + interval: 5 * time.Minute, + maxAge: 30 * time.Minute, + stop: make(chan struct{}), + } +} + +// Start begins the background cleanup goroutine. Safe to call multiple times. +func (mc *MediaCleaner) Start() { + mc.once.Do(func() { + go mc.loop() + logger.InfoC("media", "Media cleaner started") + }) +} + +// Stop signals the cleanup goroutine to exit. Safe to call multiple times. +func (mc *MediaCleaner) Stop() { + select { + case <-mc.stop: + default: + close(mc.stop) + logger.InfoC("media", "Media cleaner stopped") + } +} + +func (mc *MediaCleaner) loop() { + ticker := time.NewTicker(mc.interval) + defer ticker.Stop() + for { + select { + case <-mc.stop: + return + case <-ticker.C: + mc.cleanup() + } + } +} + +func (mc *MediaCleaner) cleanup() { + mediaDir := filepath.Join(os.TempDir(), MediaDir) + entries, err := os.ReadDir(mediaDir) + if err != nil { + return + } + + now := time.Now() + removed := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if now.Sub(info.ModTime()) > mc.maxAge { + path := filepath.Join(mediaDir, entry.Name()) + if err := os.Remove(path); err == nil { + removed++ + } + } + } + if removed > 0 { + logger.DebugCF("media", "Cleaned up old media files", map[string]any{ + "removed": removed, + }) + } +} diff --git a/pkg/utils/media_test.go b/pkg/utils/media_test.go new file mode 100644 index 000000000..05f99a4b1 --- /dev/null +++ b/pkg/utils/media_test.go @@ -0,0 +1,66 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestMediaCleanerRemovesOldFiles(t *testing.T) { + // Setup: create a temp media directory with old and new files + mediaDir := filepath.Join(os.TempDir(), MediaDir) + if err := os.MkdirAll(mediaDir, 0700); err != nil { + t.Fatalf("failed to create media dir: %v", err) + } + + // Create an "old" file and backdate its modification time + oldFile := filepath.Join(mediaDir, "test_old_file.jpg") + if err := os.WriteFile(oldFile, []byte("old"), 0600); err != nil { + t.Fatalf("failed to create old file: %v", err) + } + oldTime := time.Now().Add(-1 * time.Hour) + if err := os.Chtimes(oldFile, oldTime, oldTime); err != nil { + t.Fatalf("failed to set old file time: %v", err) + } + + // Create a "new" file (just created, so modtime is now) + newFile := filepath.Join(mediaDir, "test_new_file.jpg") + if err := os.WriteFile(newFile, []byte("new"), 0600); err != nil { + t.Fatalf("failed to create new file: %v", err) + } + + // Cleanup test files at end + defer os.Remove(oldFile) + defer os.Remove(newFile) + + // Run cleanup directly + mc := NewMediaCleaner() + mc.cleanup() + + // Old file should be gone + if _, err := os.Stat(oldFile); !os.IsNotExist(err) { + t.Errorf("expected old file to be removed, but it still exists") + } + + // New file should still exist + if _, err := os.Stat(newFile); err != nil { + t.Errorf("expected new file to still exist, got error: %v", err) + } +} + +func TestMediaCleanerStartStop(t *testing.T) { + mc := NewMediaCleaner() + + // Start should not panic + mc.Start() + + // Second Start should be idempotent (sync.Once) + mc.Start() + + // Stop should not panic + mc.Stop() + + // Second Stop should be idempotent + mc.Stop() +} From 81df10b0b16fef80e750953f910c57a73ef9e1e0 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Sun, 22 Feb 2026 14:28:51 +0900 Subject: [PATCH 2/5] fix: correct indentation and octal literals for linter Fix gci errors caused by lost indentation when removing localFiles lines, and use 0o700/0o600 octal syntax for gofumpt compliance. Co-Authored-By: Claude Opus 4.6 --- pkg/channels/line.go | 6 +++--- pkg/channels/slack.go | 2 +- pkg/channels/telegram.go | 8 ++++---- pkg/utils/media_test.go | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/channels/line.go b/pkg/channels/line.go index 009be68a1..52f78f2f8 100644 --- a/pkg/channels/line.go +++ b/pkg/channels/line.go @@ -317,19 +317,19 @@ func (c *LINEChannel) processEvent(event lineEvent) { case "image": localPath := c.downloadContent(msg.ID, "image.jpg") if localPath != "" { - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, localPath) content = "[image]" } case "audio": localPath := c.downloadContent(msg.ID, "audio.m4a") if localPath != "" { - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, localPath) content = "[audio]" } case "video": localPath := c.downloadContent(msg.ID, "video.mp4") if localPath != "" { - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, localPath) content = "[video]" } case "file": diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index d3e400e24..d23e71080 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -238,7 +238,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { if localPath == "" { continue } - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, localPath) if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() { ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 8681f7295..9e34a6a9d 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -237,7 +237,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes photo := message.Photo[len(message.Photo)-1] photoPath := c.downloadPhoto(ctx, photo.FileID) if photoPath != "" { - mediaPaths = append(mediaPaths, photoPath) + mediaPaths = append(mediaPaths, photoPath) if content != "" { content += "\n" } @@ -248,7 +248,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Voice != nil { voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") if voicePath != "" { - mediaPaths = append(mediaPaths, voicePath) + mediaPaths = append(mediaPaths, voicePath) transcribedText := "" if c.transcriber != nil && c.transcriber.IsAvailable() { @@ -282,7 +282,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Audio != nil { audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") if audioPath != "" { - mediaPaths = append(mediaPaths, audioPath) + mediaPaths = append(mediaPaths, audioPath) if content != "" { content += "\n" } @@ -293,7 +293,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Document != nil { docPath := c.downloadFile(ctx, message.Document.FileID, "") if docPath != "" { - mediaPaths = append(mediaPaths, docPath) + mediaPaths = append(mediaPaths, docPath) if content != "" { content += "\n" } diff --git a/pkg/utils/media_test.go b/pkg/utils/media_test.go index 05f99a4b1..e77908056 100644 --- a/pkg/utils/media_test.go +++ b/pkg/utils/media_test.go @@ -10,13 +10,13 @@ import ( func TestMediaCleanerRemovesOldFiles(t *testing.T) { // Setup: create a temp media directory with old and new files mediaDir := filepath.Join(os.TempDir(), MediaDir) - if err := os.MkdirAll(mediaDir, 0700); err != nil { + if err := os.MkdirAll(mediaDir, 0o700); err != nil { t.Fatalf("failed to create media dir: %v", err) } // Create an "old" file and backdate its modification time oldFile := filepath.Join(mediaDir, "test_old_file.jpg") - if err := os.WriteFile(oldFile, []byte("old"), 0600); err != nil { + if err := os.WriteFile(oldFile, []byte("old"), 0o600); err != nil { t.Fatalf("failed to create old file: %v", err) } oldTime := time.Now().Add(-1 * time.Hour) @@ -26,7 +26,7 @@ func TestMediaCleanerRemovesOldFiles(t *testing.T) { // Create a "new" file (just created, so modtime is now) newFile := filepath.Join(mediaDir, "test_new_file.jpg") - if err := os.WriteFile(newFile, []byte("new"), 0600); err != nil { + if err := os.WriteFile(newFile, []byte("new"), 0o600); err != nil { t.Fatalf("failed to create new file: %v", err) } From f0484cb6dffd2bf899380f4f6344b432b1c5b4e9 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Sun, 22 Feb 2026 21:34:58 +0900 Subject: [PATCH 3/5] feat: make MediaCleaner configurable via tools.media_cleanup Add MediaCleanupConfig with enabled, max_age_minutes, and interval_minutes fields. MediaCleaner is only started when enabled (default: true) and logs its effective settings at startup. Defaults: interval=5m, max_age=30m. Operators can tune or disable cleanup for deployments with longer async processing times. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_gateway.go | 11 ++++++++--- pkg/config/config.go | 15 +++++++++++---- pkg/config/defaults.go | 5 +++++ pkg/utils/media.go | 23 +++++++++++++++++------ pkg/utils/media_test.go | 4 ++-- 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index d299fb2d3..c38891277 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -193,8 +193,14 @@ func gatewayCmd() { fmt.Println("✓ Device event service started") } - mediaCleaner := utils.NewMediaCleaner() - mediaCleaner.Start() + if cfg.Tools.MediaCleanup.Enabled { + mediaCleaner := utils.NewMediaCleaner( + cfg.Tools.MediaCleanup.Interval, + cfg.Tools.MediaCleanup.MaxAge, + ) + mediaCleaner.Start() + defer mediaCleaner.Stop() + } if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) @@ -220,7 +226,6 @@ func gatewayCmd() { deviceService.Stop() heartbeatService.Stop() cronService.Stop() - mediaCleaner.Stop() agentLoop.Stop() channelManager.StopAll(ctx) fmt.Println("✓ Gateway stopped") diff --git a/pkg/config/config.go b/pkg/config/config.go index 036021e49..862f5742a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -452,11 +452,18 @@ type ExecConfig struct { CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` } +type MediaCleanupConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_MEDIA_CLEANUP_ENABLED"` + MaxAge int `json:"max_age_minutes" env:"PICOCLAW_TOOLS_MEDIA_CLEANUP_MAX_AGE_MINUTES"` + Interval int `json:"interval_minutes" env:"PICOCLAW_TOOLS_MEDIA_CLEANUP_INTERVAL_MINUTES"` +} + type ToolsConfig struct { - Web WebToolsConfig `json:"web"` - Cron CronToolsConfig `json:"cron"` - Exec ExecConfig `json:"exec"` - Skills SkillsToolsConfig `json:"skills"` + Web WebToolsConfig `json:"web"` + Cron CronToolsConfig `json:"cron"` + Exec ExecConfig `json:"exec"` + Skills SkillsToolsConfig `json:"skills"` + MediaCleanup MediaCleanupConfig `json:"media_cleanup"` } type SkillsToolsConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7654326e7..40f9ee021 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -303,6 +303,11 @@ func DefaultConfig() *Config { TTLSeconds: 300, }, }, + MediaCleanup: MediaCleanupConfig{ + Enabled: true, + MaxAge: 30, + Interval: 5, + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/utils/media.go b/pkg/utils/media.go index 79bc7217d..0ac4be001 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -154,12 +154,20 @@ type MediaCleaner struct { once sync.Once } -// NewMediaCleaner creates a new MediaCleaner with default settings -// (scan every 5 minutes, remove files older than 30 minutes). -func NewMediaCleaner() *MediaCleaner { +// NewMediaCleaner creates a new MediaCleaner with the given settings. +// If intervalMinutes or maxAgeMinutes are <= 0, defaults are used (5 and 30 respectively). +func NewMediaCleaner(intervalMinutes, maxAgeMinutes int) *MediaCleaner { + interval := time.Duration(intervalMinutes) * time.Minute + if interval <= 0 { + interval = 5 * time.Minute + } + maxAge := time.Duration(maxAgeMinutes) * time.Minute + if maxAge <= 0 { + maxAge = 30 * time.Minute + } return &MediaCleaner{ - interval: 5 * time.Minute, - maxAge: 30 * time.Minute, + interval: interval, + maxAge: maxAge, stop: make(chan struct{}), } } @@ -168,7 +176,10 @@ func NewMediaCleaner() *MediaCleaner { func (mc *MediaCleaner) Start() { mc.once.Do(func() { go mc.loop() - logger.InfoC("media", "Media cleaner started") + logger.InfoCF("media", "Media cleaner started", map[string]any{ + "interval": mc.interval.String(), + "max_age": mc.maxAge.String(), + }) }) } diff --git a/pkg/utils/media_test.go b/pkg/utils/media_test.go index e77908056..90a93ffa9 100644 --- a/pkg/utils/media_test.go +++ b/pkg/utils/media_test.go @@ -35,7 +35,7 @@ func TestMediaCleanerRemovesOldFiles(t *testing.T) { defer os.Remove(newFile) // Run cleanup directly - mc := NewMediaCleaner() + mc := NewMediaCleaner(5, 30) mc.cleanup() // Old file should be gone @@ -50,7 +50,7 @@ func TestMediaCleanerRemovesOldFiles(t *testing.T) { } func TestMediaCleanerStartStop(t *testing.T) { - mc := NewMediaCleaner() + mc := NewMediaCleaner(5, 30) // Start should not panic mc.Start() From 27bb2d994b94ef33cb88d7c0a8a09e2cd323381a Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Sun, 22 Feb 2026 21:37:07 +0900 Subject: [PATCH 4/5] fix: shorten env tag names to satisfy golines 120-char limit Co-Authored-By: Claude Opus 4.6 --- pkg/config/config.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 862f5742a..99c36a513 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -453,9 +453,9 @@ type ExecConfig struct { } type MediaCleanupConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_MEDIA_CLEANUP_ENABLED"` - MaxAge int `json:"max_age_minutes" env:"PICOCLAW_TOOLS_MEDIA_CLEANUP_MAX_AGE_MINUTES"` - Interval int `json:"interval_minutes" env:"PICOCLAW_TOOLS_MEDIA_CLEANUP_INTERVAL_MINUTES"` + Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"` + MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"` + Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"` } type ToolsConfig struct { From 0e5d30a0f8e3579bd7ae344eaf6e1e89fa8cc9a3 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Sun, 22 Feb 2026 21:39:17 +0900 Subject: [PATCH 5/5] style: align struct tags per golines formatter Co-Authored-By: Claude Opus 4.6 --- pkg/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 99c36a513..18ac99f5c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -453,8 +453,8 @@ type ExecConfig struct { } type MediaCleanupConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"` - MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"` + Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"` + MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"` Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"` }