From 9d93bda3fe9befae41219176a4c78f733ce1a3d3 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:04:48 +0800 Subject: [PATCH] feat: temporary file handling and introduce TempDirCleaner (#5026) * feat: temporary file handling and introduce TempDirCleaner - Updated various modules to use `get_astrbot_temp_path()` instead of `get_astrbot_data_path()` for temporary file storage. - Renamed temporary files for better identification and organization. - Introduced `TempDirCleaner` to manage the size of the temporary directory, ensuring it does not exceed a specified limit by deleting the oldest files. - Added configuration option for maximum temporary directory size in the dashboard. - Implemented tests for `TempDirCleaner` to verify cleanup functionality and size management. * ruff --- .../agent/runners/dify/dify_agent_runner.py | 6 +- astrbot/core/astr_main_agent_resources.py | 15 +- astrbot/core/computer/tools/fs.py | 15 +- astrbot/core/config/default.py | 7 + astrbot/core/core_lifecycle.py | 19 +++ astrbot/core/message/components.py | 24 +-- .../sources/dingtalk/dingtalk_adapter.py | 6 +- .../core/platform/sources/lark/lark_event.py | 9 +- .../sources/misskey/misskey_adapter.py | 4 +- .../qqofficial/qqofficial_message_event.py | 6 +- .../platform/sources/wecom/wecom_adapter.py | 9 +- .../weixin_offacc_adapter.py | 10 +- .../core/provider/sources/azure_tts_source.py | 3 +- .../core/provider/sources/dashscope_tts.py | 4 +- .../core/provider/sources/edge_tts_source.py | 4 +- .../sources/fishaudio_tts_api_source.py | 4 +- .../provider/sources/gemini_tts_source.py | 4 +- astrbot/core/provider/sources/genie_tts.py | 6 +- .../provider/sources/gsv_selfhosted_source.py | 4 +- .../core/provider/sources/gsvi_tts_source.py | 4 +- .../sources/minimax_tts_api_source.py | 4 +- .../provider/sources/openai_tts_api_source.py | 4 +- .../core/provider/sources/volcengine_tts.py | 10 +- .../provider/sources/whisper_api_source.py | 17 +- .../sources/whisper_selfhosted_source.py | 17 +- .../sources/xinference_stt_provider.py | 14 +- astrbot/core/skills/skill_manager.py | 1 - astrbot/core/utils/io.py | 17 +- astrbot/core/utils/media_utils.py | 21 +-- astrbot/core/utils/temp_dir_cleaner.py | 150 ++++++++++++++++++ astrbot/core/utils/tencent_record_helper.py | 6 +- astrbot/dashboard/routes/knowledge_base.py | 6 +- astrbot/dashboard/routes/live_chat.py | 4 +- astrbot/dashboard/routes/plugin.py | 6 +- .../en-US/features/config-metadata.json | 4 + .../zh-CN/features/config-metadata.json | 4 + tests/test_temp_dir_cleaner.py | 52 ++++++ 37 files changed, 388 insertions(+), 112 deletions(-) create mode 100644 astrbot/core/utils/temp_dir_cleaner.py create mode 100644 tests/test_temp_dir_cleaner.py diff --git a/astrbot/core/agent/runners/dify/dify_agent_runner.py b/astrbot/core/agent/runners/dify/dify_agent_runner.py index d9a8b7cd6..93f8d3570 100644 --- a/astrbot/core/agent/runners/dify/dify_agent_runner.py +++ b/astrbot/core/agent/runners/dify/dify_agent_runner.py @@ -10,7 +10,7 @@ from astrbot.core.provider.entities import ( LLMResponse, ProviderRequest, ) -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from ...hooks import BaseAgentRunHooks @@ -291,8 +291,8 @@ class DifyAgentRunner(BaseAgentRunner[TContext]): return Comp.Image(file=item["url"], url=item["url"]) case "audio": # 仅支持 wav - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - path = os.path.join(temp_dir, f"{item['filename']}.wav") + temp_dir = get_astrbot_temp_path() + path = os.path.join(temp_dir, f"dify_{item['filename']}.wav") await download_file(item["url"], path) return Comp.Image(file=item["url"], url=item["url"]) case "video": diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index 1d5c085ce..634647e7a 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -1,6 +1,7 @@ import base64 import json import os +import uuid from pydantic import Field from pydantic.dataclasses import dataclass @@ -240,7 +241,9 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): if "_&exists_" in json.dumps(result): # Download the file from sandbox name = os.path.basename(path) - local_path = os.path.join(get_astrbot_temp_path(), name) + local_path = os.path.join( + get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" + ) await sb.download_file(path, local_path) logger.info(f"Downloaded file from sandbox: {path} -> {local_path}") return local_path, True @@ -352,11 +355,11 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): MessageChain(chain=components), ) - if file_from_sandbox: - try: - os.remove(local_path) - except Exception as e: - logger.error(f"Error removing temp file {local_path}: {e}") + # if file_from_sandbox: + # try: + # os.remove(local_path) + # except Exception as e: + # logger.error(f"Error removing temp file {local_path}: {e}") return f"Message sent to session {target_session}" diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py index 9cf590a61..126da4258 100644 --- a/astrbot/core/computer/tools/fs.py +++ b/astrbot/core/computer/tools/fs.py @@ -1,4 +1,5 @@ import os +import uuid from dataclasses import dataclass, field from astrbot.api import FunctionTool, logger @@ -167,7 +168,9 @@ class FileDownloadTool(FunctionTool): try: name = os.path.basename(remote_path) - local_path = os.path.join(get_astrbot_temp_path(), name) + local_path = os.path.join( + get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" + ) # Download file from sandbox await sb.download_file(remote_path, local_path) @@ -183,12 +186,12 @@ class FileDownloadTool(FunctionTool): logger.error(f"Error sending file message: {e}") # remove - try: - os.remove(local_path) - except Exception as e: - logger.error(f"Error removing temp file {local_path}: {e}") + # try: + # os.remove(local_path) + # except Exception as e: + # logger.error(f"Error removing temp file {local_path}: {e}") - return f"File downloaded successfully to {local_path} and sent to user. The file has been removed from local storage." + return f"File downloaded successfully to {local_path} and sent to user." return f"File downloaded successfully to {local_path}" except Exception as e: diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 546768812..411384c1d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -203,6 +203,7 @@ DEFAULT_CONFIG = { "log_file_enable": False, "log_file_path": "logs/astrbot.log", "log_file_max_mb": 20, + "temp_dir_max_size": 1024, "trace_enable": False, "trace_log_enable": False, "trace_log_path": "logs/astrbot.trace.log", @@ -2394,6 +2395,7 @@ CONFIG_METADATA_2 = { "log_file_enable": {"type": "bool"}, "log_file_path": {"type": "string", "condition": {"log_file_enable": True}}, "log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}}, + "temp_dir_max_size": {"type": "int"}, "trace_log_enable": {"type": "bool"}, "trace_log_path": { "type": "string", @@ -3372,6 +3374,11 @@ CONFIG_METADATA_3_SYSTEM = { "type": "int", "hint": "超过大小后自动轮转,默认 20MB。", }, + "temp_dir_max_size": { + "description": "临时目录大小上限 (MB)", + "type": "int", + "hint": "用于限制 data/temp 目录总大小,单位为 MB。系统每 10 分钟检查一次,超限时按文件修改时间从旧到新删除,释放约 30% 当前体积。", + }, "trace_log_enable": { "description": "启用 Trace 文件日志", "type": "bool", diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 6b36cca0d..758cf1ccd 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -37,6 +37,7 @@ from astrbot.core.umop_config_router import UmopConfigRouter from astrbot.core.updator import AstrBotUpdator from astrbot.core.utils.llm_metadata import update_llm_metadata from astrbot.core.utils.migra_helper import migra +from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner from . import astrbot_config, html_renderer from .event_bus import EventBus @@ -57,6 +58,7 @@ class AstrBotCoreLifecycle: self.subagent_orchestrator: SubAgentOrchestrator | None = None self.cron_manager: CronJobManager | None = None + self.temp_dir_cleaner: TempDirCleaner | None = None # 设置代理 proxy_config = self.astrbot_config.get("http_proxy", "") @@ -125,6 +127,12 @@ class AstrBotCoreLifecycle: ucr=self.umop_config_router, sp=sp, ) + self.temp_dir_cleaner = TempDirCleaner( + max_size_getter=lambda: self.astrbot_config_mgr.default_conf.get( + TempDirCleaner.CONFIG_KEY, + TempDirCleaner.DEFAULT_MAX_SIZE, + ), + ) # apply migration try: @@ -238,6 +246,12 @@ class AstrBotCoreLifecycle: self.cron_manager.start(self.star_context), name="cron_manager", ) + temp_dir_cleaner_task = None + if self.temp_dir_cleaner: + temp_dir_cleaner_task = asyncio.create_task( + self.temp_dir_cleaner.run(), + name="temp_dir_cleaner", + ) # 把插件中注册的所有协程函数注册到事件总线中并执行 extra_tasks = [] @@ -247,6 +261,8 @@ class AstrBotCoreLifecycle: tasks_ = [event_bus_task, *(extra_tasks if extra_tasks else [])] if cron_task: tasks_.append(cron_task) + if temp_dir_cleaner_task: + tasks_.append(temp_dir_cleaner_task) for task in tasks_: self.curr_tasks.append( asyncio.create_task(self._task_wrapper(task), name=task.get_name()), @@ -298,6 +314,9 @@ class AstrBotCoreLifecycle: async def stop(self) -> None: """停止 AstrBot 核心生命周期管理类, 取消所有当前任务并终止各个管理器.""" + if self.temp_dir_cleaner: + await self.temp_dir_cleaner.stop() + # 请求停止所有正在运行的异步任务 for task in self.curr_tasks: task.cancel() diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index a192025dc..2c86bf3d9 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -31,7 +31,7 @@ from enum import Enum from pydantic.v1 import BaseModel from astrbot.core import astrbot_config, file_token_service, logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64 @@ -156,8 +156,9 @@ class Record(BaseMessageComponent): if self.file.startswith("base64://"): bs64_data = self.file.removeprefix("base64://") image_bytes = base64.b64decode(bs64_data) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg") + file_path = os.path.join( + get_astrbot_temp_path(), f"recordseg_{uuid.uuid4()}.jpg" + ) with open(file_path, "wb") as f: f.write(image_bytes) return os.path.abspath(file_path) @@ -245,8 +246,9 @@ class Video(BaseMessageComponent): if url and url.startswith("file:///"): return url[8:] if url and url.startswith("http"): - download_dir = os.path.join(get_astrbot_data_path(), "temp") - video_file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}") + video_file_path = os.path.join( + get_astrbot_temp_path(), f"videoseg_{uuid.uuid4().hex}" + ) await download_file(url, video_file_path) if os.path.exists(video_file_path): return os.path.abspath(video_file_path) @@ -445,8 +447,9 @@ class Image(BaseMessageComponent): if url.startswith("base64://"): bs64_data = url.removeprefix("base64://") image_bytes = base64.b64decode(bs64_data) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - image_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg") + image_file_path = os.path.join( + get_astrbot_temp_path(), f"imgseg_{uuid.uuid4()}.jpg" + ) with open(image_file_path, "wb") as f: f.write(image_bytes) return os.path.abspath(image_file_path) @@ -725,13 +728,12 @@ class File(BaseMessageComponent): """下载文件""" if not self.url: raise ValueError("Download failed: No URL provided in File component.") - download_dir = os.path.join(get_astrbot_data_path(), "temp") - os.makedirs(download_dir, exist_ok=True) + download_dir = get_astrbot_temp_path() if self.name: name, ext = os.path.splitext(self.name) - filename = f"{name}_{uuid.uuid4().hex[:8]}{ext}" + filename = f"fileseg_{name}_{uuid.uuid4().hex[:8]}{ext}" else: - filename = f"{uuid.uuid4().hex}" + filename = f"fileseg_{uuid.uuid4().hex}" file_path = os.path.join(download_dir, filename) await download_file(self.url, file_path) self.file_ = os.path.abspath(file_path) diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index fd0be3f1c..2d9b45cc1 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -21,7 +21,7 @@ from astrbot.api.platform import ( ) from astrbot.core import sp from astrbot.core.platform.astr_message_event import MessageSesion -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.media_utils import ( convert_audio_format, @@ -253,9 +253,9 @@ class DingtalkPlatformAdapter(Platform): "downloadCode": download_code, "robotCode": robot_code, } - temp_dir = Path(get_astrbot_data_path()) / "temp" + temp_dir = Path(get_astrbot_temp_path()) temp_dir.mkdir(parents=True, exist_ok=True) - f_path = temp_dir / f"dingtalk_file_{uuid.uuid4()}.{ext}" + f_path = temp_dir / f"dingtalk_{uuid.uuid4()}.{ext}" async with ( aiohttp.ClientSession() as session, session.post( diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 83a455888..92e3a32b9 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -21,7 +21,7 @@ from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import At, File, Plain, Record, Video from astrbot.api.message_components import Image as AstrBotImage -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.media_utils import ( convert_audio_to_opus, @@ -202,8 +202,11 @@ class LarkMessageEvent(AstrMessageEvent): base64_str = comp.file.removeprefix("base64://") image_data = base64.b64decode(base64_str) # save as temp file - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - file_path = os.path.join(temp_dir, f"{uuid.uuid4()}_test.jpg") + temp_dir = get_astrbot_temp_path() + file_path = os.path.join( + temp_dir, + f"lark_image_{uuid.uuid4().hex[:8]}.jpg", + ) with open(file_path, "wb") as f: f.write(BytesIO(image_data).getvalue()) else: diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 34415b855..fd61c3e50 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -21,7 +21,7 @@ try: except Exception: magic = None -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .misskey_event import MisskeyPlatformEvent from .misskey_utils import ( @@ -498,7 +498,7 @@ class MisskeyPlatformAdapter(Platform): finally: # 清理临时文件 if local_path and isinstance(local_path, str): - data_temp = os.path.join(get_astrbot_data_path(), "temp") + data_temp = get_astrbot_temp_path() if local_path.startswith(data_temp) and os.path.exists( local_path, ): diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 88c8fc225..1af4de49b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -19,7 +19,7 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import Image, Plain, Record from astrbot.api.platform import AstrBotMessage, PlatformMetadata -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_image_by_url, file_to_base64 from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk @@ -350,10 +350,10 @@ class QQOfficialMessageEvent(AstrMessageEvent): elif isinstance(i, Record): if i.file: record_wav_path = await i.convert_to_file_path() # wav 路径 - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() record_tecent_silk_path = os.path.join( temp_dir, - f"{uuid.uuid4()}.silk", + f"qqofficial_{uuid.uuid4()}.silk", ) try: duration = await wav_to_tencent_silk( diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 0a2e71b61..6647db89f 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -25,7 +25,7 @@ from astrbot.api.platform import ( ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import convert_audio_to_wav from astrbot.core.utils.webhook_utils import log_webhook_info @@ -344,7 +344,7 @@ class WecomPlatformAdapter(Platform): self.client.media.download, msg.media_id, ) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"wecom_{msg.media_id}.amr") with open(path, "wb") as f: f.write(resp.content) @@ -400,7 +400,8 @@ class WecomPlatformAdapter(Platform): self.client.media.download, media_id, ) - path = f"data/temp/wechat_kf_{media_id}.jpg" + temp_dir = get_astrbot_temp_path() + path = os.path.join(temp_dir, f"weixinkefu_{media_id}.jpg") with open(path, "wb") as f: f.write(resp.content) abm.message = [Image(file=path, url=path)] @@ -412,7 +413,7 @@ class WecomPlatformAdapter(Platform): media_id, ) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"weixinkefu_{media_id}.amr") with open(path, "wb") as f: f.write(resp.content) diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py index 8f12ec82b..28985f757 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -1,4 +1,5 @@ import asyncio +import os import sys import uuid from collections.abc import Awaitable, Callable @@ -24,6 +25,7 @@ from astrbot.api.platform import ( ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import convert_audio_to_wav from astrbot.core.utils.webhook_utils import log_webhook_info @@ -290,12 +292,16 @@ class WeixinOfficialAccountPlatformAdapter(Platform): self.client.media.download, msg.media_id, ) - path = f"data/temp/wecom_{msg.media_id}.amr" + temp_dir = get_astrbot_temp_path() + path = os.path.join(temp_dir, f"weixin_offacc_{msg.media_id}.amr") with open(path, "wb") as f: f.write(resp.content) try: - path_wav = f"data/temp/wecom_{msg.media_id}.wav" + path_wav = os.path.join( + temp_dir, + f"weixin_offacc_{msg.media_id}.wav", + ) path_wav = await convert_audio_to_wav(path, path_wav) except Exception as e: logger.error( diff --git a/astrbot/core/provider/sources/azure_tts_source.py b/astrbot/core/provider/sources/azure_tts_source.py index 29dc3f7a4..0e8f00ce5 100644 --- a/astrbot/core/provider/sources/azure_tts_source.py +++ b/astrbot/core/provider/sources/azure_tts_source.py @@ -12,12 +12,13 @@ from httpx import AsyncClient, Timeout from astrbot import logger from astrbot.core.config.default import VERSION +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider from ..register import register_provider_adapter -TEMP_DIR = Path("data/temp/azure_tts") +TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts" TEMP_DIR.mkdir(parents=True, exist_ok=True) diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index 50bc421fd..9b6816859 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -15,7 +15,7 @@ except ( ): # pragma: no cover - older dashscope versions without Qwen TTS support MultiModalConversation = None -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -45,7 +45,7 @@ class ProviderDashscopeTTSAPI(TTSProvider): if not model: raise RuntimeError("Dashscope TTS model is not configured.") - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) if self._is_qwen_tts_model(model): diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py index 71a5a82d6..503bd275b 100644 --- a/astrbot/core/provider/sources/edge_tts_source.py +++ b/astrbot/core/provider/sources/edge_tts_source.py @@ -6,7 +6,7 @@ import uuid import edge_tts from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -46,7 +46,7 @@ class ProviderEdgeTTS(TTSProvider): self.set_model("edge_tts") async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() mp3_path = os.path.join(temp_dir, f"edge_tts_temp_{uuid.uuid4()}.mp3") wav_path = os.path.join(temp_dir, f"edge_tts_{uuid.uuid4()}.wav") diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py index dde2736a8..35945b7b6 100644 --- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -8,7 +8,7 @@ from httpx import AsyncClient from pydantic import BaseModel, conint from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -142,7 +142,7 @@ class ProviderFishAudioTTSAPI(TTSProvider): ) async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav") self.headers["content-type"] = "application/msgpack" request = await self._generate_request(text) diff --git a/astrbot/core/provider/sources/gemini_tts_source.py b/astrbot/core/provider/sources/gemini_tts_source.py index 37022f761..d6954ef82 100644 --- a/astrbot/core/provider/sources/gemini_tts_source.py +++ b/astrbot/core/provider/sources/gemini_tts_source.py @@ -6,7 +6,7 @@ from google import genai from google.genai import types from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -49,7 +49,7 @@ class ProviderGeminiTTSAPI(TTSProvider): self.voice_name: str = provider_config.get("gemini_tts_voice_name", "Leda") async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"gemini_tts_{uuid.uuid4()}.wav") prompt = f"{self.prefix}: {text}" if self.prefix else text response = await self.client.models.generate_content( diff --git a/astrbot/core/provider/sources/genie_tts.py b/astrbot/core/provider/sources/genie_tts.py index bca92deb7..8f9b6d91d 100644 --- a/astrbot/core/provider/sources/genie_tts.py +++ b/astrbot/core/provider/sources/genie_tts.py @@ -6,7 +6,7 @@ from astrbot.core import logger from astrbot.core.provider.entities import ProviderType from astrbot.core.provider.provider import TTSProvider from astrbot.core.provider.register import register_provider_adapter -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path try: import genie_tts as genie # type: ignore @@ -54,7 +54,7 @@ class GenieTTSProvider(TTSProvider): return True async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) filename = f"genie_tts_{uuid.uuid4()}.wav" path = os.path.join(temp_dir, filename) @@ -94,7 +94,7 @@ class GenieTTSProvider(TTSProvider): break try: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) filename = f"genie_tts_{uuid.uuid4()}.wav" path = os.path.join(temp_dir, filename) diff --git a/astrbot/core/provider/sources/gsv_selfhosted_source.py b/astrbot/core/provider/sources/gsv_selfhosted_source.py index 029f6af10..fc8bccea8 100644 --- a/astrbot/core/provider/sources/gsv_selfhosted_source.py +++ b/astrbot/core/provider/sources/gsv_selfhosted_source.py @@ -5,7 +5,7 @@ import uuid import aiohttp from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -121,7 +121,7 @@ class ProviderGSVTTS(TTSProvider): params = self.build_synthesis_params(text) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) path = os.path.join(temp_dir, f"gsv_tts_{uuid.uuid4().hex}.wav") diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index d8b171718..425e801f4 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -4,7 +4,7 @@ import uuid import aiohttp -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -29,7 +29,7 @@ class ProviderGSVITTS(TTSProvider): self.emotion = provider_config.get("emotion") async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav") params = {"text": text} diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py index dcd29060e..69860111c 100644 --- a/astrbot/core/provider/sources/minimax_tts_api_source.py +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -6,7 +6,7 @@ from collections.abc import AsyncIterator import aiohttp from astrbot.api import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -145,7 +145,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider): return b"".join(chunks) async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3") diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index 489a37b2d..217b18925 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -5,7 +5,7 @@ import httpx from openai import NOT_GIVEN, AsyncOpenAI from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -46,7 +46,7 @@ class ProviderOpenAITTSAPI(TTSProvider): self.set_model(provider_config.get("model", "")) async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"openai_tts_api_{uuid.uuid4()}.wav") async with self.client.audio.speech.with_streaming_response.create( model=self.model_name, diff --git a/astrbot/core/provider/sources/volcengine_tts.py b/astrbot/core/provider/sources/volcengine_tts.py index f5d758f5c..349815907 100644 --- a/astrbot/core/provider/sources/volcengine_tts.py +++ b/astrbot/core/provider/sources/volcengine_tts.py @@ -8,6 +8,7 @@ import uuid import aiohttp from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -92,9 +93,12 @@ class ProviderVolcengineTTS(TTSProvider): if "data" in resp_data: audio_data = base64.b64decode(resp_data["data"]) - os.makedirs("data/temp", exist_ok=True) - - file_path = f"data/temp/volcengine_tts_{uuid.uuid4()}.mp3" + temp_dir = get_astrbot_temp_path() + os.makedirs(temp_dir, exist_ok=True) + file_path = os.path.join( + temp_dir, + f"volcengine_tts_{uuid.uuid4()}.mp3", + ) loop = asyncio.get_running_loop() await loop.run_in_executor( diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index 875881b7b..386da063d 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -4,7 +4,7 @@ import uuid from openai import NOT_GIVEN, AsyncOpenAI from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import ( convert_to_pcm_wav, @@ -65,9 +65,11 @@ class ProviderOpenAIWhisperAPI(STTProvider): if "multimedia.nt.qq.com.cn" in audio_url: is_tencent = True - name = str(uuid.uuid4()) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - path = os.path.join(temp_dir, name) + temp_dir = get_astrbot_temp_path() + path = os.path.join( + temp_dir, + f"whisper_api_{uuid.uuid4().hex[:8]}.input", + ) await download_file(audio_url, path) audio_url = path @@ -79,8 +81,11 @@ class ProviderOpenAIWhisperAPI(STTProvider): # 判断是否需要转换 if file_format in ["silk", "amr"]: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") + temp_dir = get_astrbot_temp_path() + output_path = os.path.join( + temp_dir, + f"whisper_api_{uuid.uuid4().hex[:8]}.wav", + ) if file_format == "silk": logger.info( diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py index d5d2dc340..678deb948 100644 --- a/astrbot/core/provider/sources/whisper_selfhosted_source.py +++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py @@ -6,7 +6,7 @@ from typing import cast import whisper from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav @@ -58,9 +58,11 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): if "multimedia.nt.qq.com.cn" in audio_url: is_tencent = True - name = str(uuid.uuid4()) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - path = os.path.join(temp_dir, name) + temp_dir = get_astrbot_temp_path() + path = os.path.join( + temp_dir, + f"whisper_selfhost_{uuid.uuid4().hex[:8]}.input", + ) await download_file(audio_url, path) audio_url = path @@ -71,8 +73,11 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): is_silk = await self._is_silk_file(audio_url) if is_silk: logger.info("Converting silk file to wav ...") - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") + temp_dir = get_astrbot_temp_path() + output_path = os.path.join( + temp_dir, + f"whisper_selfhost_{uuid.uuid4().hex[:8]}.wav", + ) await tencent_silk_to_wav(audio_url, output_path) audio_url = output_path diff --git a/astrbot/core/provider/sources/xinference_stt_provider.py b/astrbot/core/provider/sources/xinference_stt_provider.py index a3e5be352..0a22e456e 100644 --- a/astrbot/core/provider/sources/xinference_stt_provider.py +++ b/astrbot/core/provider/sources/xinference_stt_provider.py @@ -7,7 +7,7 @@ from xinference_client.client.restful.async_restful_client import ( ) from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.tencent_record_helper import ( convert_to_pcm_wav, tencent_silk_to_wav, @@ -130,11 +130,17 @@ class ProviderXinferenceSTT(STTProvider): logger.info( f"Audio requires conversion ({conversion_type}), using temporary files..." ) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) - input_path = os.path.join(temp_dir, str(uuid.uuid4())) - output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") + input_path = os.path.join( + temp_dir, + f"xinference_stt_{uuid.uuid4().hex[:8]}.input", + ) + output_path = os.path.join( + temp_dir, + f"xinference_stt_{uuid.uuid4().hex[:8]}.wav", + ) temp_files.extend([input_path, output_path]) with open(input_path, "wb") as f: diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 1e6f01a6d..85190ecdf 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -93,7 +93,6 @@ class SkillManager: self.skills_root = skills_root or get_astrbot_skills_path() self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME) os.makedirs(self.skills_root, exist_ok=True) - os.makedirs(get_astrbot_temp_path(), exist_ok=True) def _load_config(self) -> dict: if not os.path.exists(self.config_path): diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 24b919bdd..0ce3624e8 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -14,7 +14,7 @@ import certifi import psutil from PIL import Image -from .astrbot_path import get_astrbot_data_path +from .astrbot_path import get_astrbot_data_path, get_astrbot_temp_path logger = logging.getLogger("astrbot") @@ -50,21 +50,10 @@ def port_checker(port: int, host: str = "localhost") -> bool: def save_temp_img(img: Image.Image | bytes) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - # 获得文件创建时间,清除超过 12 小时的 - try: - for f in os.listdir(temp_dir): - path = os.path.join(temp_dir, f) - if os.path.isfile(path): - ctime = os.path.getctime(path) - if time.time() - ctime > 3600 * 12: - os.remove(path) - except Exception as e: - print(f"清除临时文件失败: {e}") - + temp_dir = get_astrbot_temp_path() # 获得时间戳 timestamp = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" - p = os.path.join(temp_dir, f"{timestamp}.jpg") + p = os.path.join(temp_dir, f"io_temp_img_{timestamp}.jpg") if isinstance(img, Image.Image): img.save(p) diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index e2abb0744..8d833514f 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -10,7 +10,7 @@ import uuid from pathlib import Path from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path async def get_media_duration(file_path: str) -> int | None: @@ -77,9 +77,9 @@ async def convert_audio_to_opus(audio_path: str, output_path: str | None = None) # 生成输出文件路径 if output_path is None: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) - output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.opus") + output_path = os.path.join(temp_dir, f"media_audio_{uuid.uuid4().hex}.opus") try: # 使用ffmpeg转换为opus格式 @@ -156,9 +156,12 @@ async def convert_video_format( # 生成输出文件路径 if output_path is None: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) - output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.{output_format}") + output_path = os.path.join( + temp_dir, + f"media_video_{uuid.uuid4().hex}.{output_format}", + ) try: # 使用ffmpeg转换视频格式 @@ -227,9 +230,9 @@ async def convert_audio_format( return audio_path if output_path is None: - temp_dir = Path(get_astrbot_data_path()) / "temp" + temp_dir = Path(get_astrbot_temp_path()) temp_dir.mkdir(parents=True, exist_ok=True) - output_path = str(temp_dir / f"{uuid.uuid4()}.{output_format}") + output_path = str(temp_dir / f"media_audio_{uuid.uuid4().hex}.{output_format}") args = ["ffmpeg", "-y", "-i", audio_path] if output_format == "amr": @@ -283,9 +286,9 @@ async def extract_video_cover( ) -> str: """从视频中提取封面图(JPG)。""" if output_path is None: - temp_dir = Path(get_astrbot_data_path()) / "temp" + temp_dir = Path(get_astrbot_temp_path()) temp_dir.mkdir(parents=True, exist_ok=True) - output_path = str(temp_dir / f"{uuid.uuid4()}.jpg") + output_path = str(temp_dir / f"media_cover_{uuid.uuid4().hex}.jpg") try: process = await asyncio.create_subprocess_exec( diff --git a/astrbot/core/utils/temp_dir_cleaner.py b/astrbot/core/utils/temp_dir_cleaner.py new file mode 100644 index 000000000..c0c060098 --- /dev/null +++ b/astrbot/core/utils/temp_dir_cleaner.py @@ -0,0 +1,150 @@ +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + + +def parse_size_to_bytes(value: str | int | float | None) -> int: + """Parse size in MB to bytes.""" + if value is None: + return 0 + + try: + size_mb = float(str(value).strip()) + except (TypeError, ValueError): + return 0 + + if size_mb <= 0: + return 0 + + return int(size_mb * 1024**2) + + +@dataclass +class TempFileInfo: + path: Path + size: int + mtime: float + + +class TempDirCleaner: + CONFIG_KEY = "temp_dir_max_size" + DEFAULT_MAX_SIZE = 1024 + CHECK_INTERVAL_SECONDS = 10 * 60 + CLEANUP_RATIO = 0.30 + + def __init__( + self, + max_size_getter: Callable[[], str | int | float | None], + temp_dir: Path | None = None, + ) -> None: + self._max_size_getter = max_size_getter + self._temp_dir = temp_dir or Path(get_astrbot_temp_path()) + self._stop_event = asyncio.Event() + + def _limit_bytes(self) -> int: + configured = self._max_size_getter() + parsed = parse_size_to_bytes(configured) + if parsed <= 0: + fallback = parse_size_to_bytes(self.DEFAULT_MAX_SIZE) + logger.warning( + f"Invalid {self.CONFIG_KEY}={configured!r}, fallback to {self.DEFAULT_MAX_SIZE}MB.", + ) + return fallback + return parsed + + def _scan_temp_files(self) -> tuple[int, list[TempFileInfo]]: + if not self._temp_dir.exists(): + return 0, [] + + total_size = 0 + files: list[TempFileInfo] = [] + for path in self._temp_dir.rglob("*"): + if not path.is_file(): + continue + try: + stat = path.stat() + except OSError as e: + logger.debug(f"Skip temp file {path} due to stat error: {e}") + continue + total_size += stat.st_size + files.append( + TempFileInfo(path=path, size=stat.st_size, mtime=stat.st_mtime) + ) + + return total_size, files + + def _cleanup_empty_dirs(self) -> None: + if not self._temp_dir.exists(): + return + for path in sorted( + self._temp_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True + ): + if not path.is_dir(): + continue + try: + path.rmdir() + except OSError: + continue + + def cleanup_once(self) -> None: + limit = self._limit_bytes() + if limit <= 0: + return + + total_size, files = self._scan_temp_files() + if total_size <= limit: + return + + target_release = max(int(total_size * self.CLEANUP_RATIO), 1) + released = 0 + removed_files = 0 + + for file_info in sorted(files, key=lambda item: item.mtime): + try: + file_info.path.unlink() + except OSError as e: + logger.warning(f"Failed to delete temp file {file_info.path}: {e}") + continue + + released += file_info.size + removed_files += 1 + if released >= target_release: + break + + self._cleanup_empty_dirs() + + logger.warning( + f"Temp dir exceeded limit ({total_size} > {limit}). " + f"Removed {removed_files} files, released {released} bytes " + f"(target {target_release} bytes).", + ) + + async def run(self) -> None: + logger.info( + f"TempDirCleaner started. interval={self.CHECK_INTERVAL_SECONDS}s " + f"cleanup_ratio={self.CLEANUP_RATIO}", + ) + while not self._stop_event.is_set(): + try: + # File-system traversal and deletion are blocking operations. + # Run cleanup in a worker thread to avoid blocking the event loop. + await asyncio.to_thread(self.cleanup_once) + except Exception as e: + logger.error(f"TempDirCleaner run failed: {e}", exc_info=True) + + try: + await asyncio.wait_for( + self._stop_event.wait(), + timeout=self.CHECK_INTERVAL_SECONDS, + ) + except asyncio.TimeoutError: + continue + + logger.info("TempDirCleaner stopped.") + + async def stop(self) -> None: + self._stop_event.set() diff --git a/astrbot/core/utils/tencent_record_helper.py b/astrbot/core/utils/tencent_record_helper.py index b58643bd3..f342484bd 100644 --- a/astrbot/core/utils/tencent_record_helper.py +++ b/astrbot/core/utils/tencent_record_helper.py @@ -7,7 +7,7 @@ import wave from io import BytesIO from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str: @@ -117,12 +117,13 @@ async def audio_to_tencent_silk_base64(audio_path: str) -> tuple[str, float]: except ImportError as e: raise Exception("未安装 pilk: pip install pilk") from e - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) # 是否需要转换为 WAV ext = os.path.splitext(audio_path)[1].lower() temp_wav = tempfile.NamedTemporaryFile( + prefix="tencent_record_", suffix=".wav", delete=False, dir=temp_dir, @@ -140,6 +141,7 @@ async def audio_to_tencent_silk_base64(audio_path: str) -> tuple[str, float]: rate = wav_file.getframerate() silk_path = tempfile.NamedTemporaryFile( + prefix="tencent_record_", suffix=".silk", delete=False, dir=temp_dir, diff --git a/astrbot/dashboard/routes/knowledge_base.py b/astrbot/dashboard/routes/knowledge_base.py index efdc3bc6a..f0ac5d43d 100644 --- a/astrbot/dashboard/routes/knowledge_base.py +++ b/astrbot/dashboard/routes/knowledge_base.py @@ -12,6 +12,7 @@ from quart import request from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..utils import generate_tsne_visualization from .route import Response, Route, RouteContext @@ -703,7 +704,10 @@ class KnowledgeBaseRoute(Route): file_name = file.filename # 保存到临时文件 - temp_file_path = f"data/temp/{uuid.uuid4()}_{file_name}" + temp_file_path = os.path.join( + get_astrbot_temp_path(), + f"kb_upload_{uuid.uuid4()}_{file_name}", + ) await file.save(temp_file_path) try: diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py index 85af3cef8..8c922ab69 100644 --- a/astrbot/dashboard/routes/live_chat.py +++ b/astrbot/dashboard/routes/live_chat.py @@ -12,7 +12,7 @@ from quart import websocket from astrbot import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .route import Route, RouteContext @@ -60,7 +60,7 @@ class LiveChatSession: # 组装 WAV 文件 try: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) audio_path = os.path.join(temp_dir, f"live_audio_{uuid.uuid4()}.wav") diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index f9f8961b4..bfdad5560 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -20,6 +20,7 @@ from astrbot.core.star.filter.permission import PermissionTypeFilter from astrbot.core.star.filter.regex import RegexFilter from astrbot.core.star.star_handler import EventType, star_handlers_registry from astrbot.core.star.star_manager import PluginManager +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .route import Response, Route, RouteContext @@ -431,7 +432,10 @@ class PluginRoute(Route): file = await request.files file = file["file"] logger.info(f"正在安装用户上传的插件 {file.filename}") - file_path = f"data/temp/{file.filename}" + file_path = os.path.join( + get_astrbot_temp_path(), + f"plugin_upload_{file.filename}", + ) await file.save(file_path) plugin_info = await self.plugin_manager.install_plugin_from_file(file_path) # self.core_lifecycle.restart() diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 2166d5391..e1019cc8a 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -819,6 +819,10 @@ "description": "Log File Max Size (MB)", "hint": "Rotate when exceeding this size; default 20MB." }, + "temp_dir_max_size": { + "description": "Temp Directory Size Limit (MB)", + "hint": "Limits total size of data/temp in MB. The system checks every 10 minutes, and when exceeded, deletes oldest files first to release about 30% of current size." + }, "trace_log_enable": { "description": "Enable Trace File Logging", "hint": "Write trace events to a separate file (does not change console output)." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 2d1c11cda..67681aa1d 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -822,6 +822,10 @@ "description": "日志文件大小上限 (MB)", "hint": "超过大小后自动轮转,默认 20MB。" }, + "temp_dir_max_size": { + "description": "临时目录大小上限 (MB)", + "hint": "用于限制 data/temp 目录总大小,单位为 MB。系统每 10 分钟检查一次,超限时按文件修改时间从旧到新删除,释放约 30% 当前体积。" + }, "trace_log_enable": { "description": "启用 Trace 文件日志", "hint": "将 Trace 事件写入独立文件(不影响控制台输出)。" diff --git a/tests/test_temp_dir_cleaner.py b/tests/test_temp_dir_cleaner.py new file mode 100644 index 000000000..01f3e65d0 --- /dev/null +++ b/tests/test_temp_dir_cleaner.py @@ -0,0 +1,52 @@ +import os +import time +from pathlib import Path + +from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner, parse_size_to_bytes + + +def test_parse_size_to_bytes(): + assert parse_size_to_bytes("1024") == 1024 * 1024**2 + assert parse_size_to_bytes(2048) == 2048 * 1024**2 + assert parse_size_to_bytes("0.5") == int(0.5 * 1024**2) + assert parse_size_to_bytes(0) == 0 + assert parse_size_to_bytes("invalid") == 0 + + +def _write_file(path: Path, size: int, mtime: float) -> None: + path.write_bytes(b"x" * size) + os.utime(path, (mtime, mtime)) + + +def test_cleanup_once_releases_30_percent_and_prefers_old_files(tmp_path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir(parents=True, exist_ok=True) + + base_time = time.time() - 1000 + file_old = temp_dir / "old.bin" + file_mid = temp_dir / "mid.bin" + file_new = temp_dir / "new.bin" + _write_file(file_old, 400, base_time) + _write_file(file_mid, 300, base_time + 10) + _write_file(file_new, 300, base_time + 20) + + cleaner = TempDirCleaner(max_size_getter=lambda: "0.0008", temp_dir=temp_dir) + cleaner.cleanup_once() + + remaining_size = sum(f.stat().st_size for f in temp_dir.rglob("*") if f.is_file()) + assert remaining_size <= 600 + assert not file_old.exists() + assert file_mid.exists() + assert file_new.exists() + + +def test_cleanup_once_noop_when_below_limit(tmp_path): + temp_dir = tmp_path / "temp" + temp_dir.mkdir(parents=True, exist_ok=True) + file_path = temp_dir / "a.bin" + _write_file(file_path, 100, time.time()) + + cleaner = TempDirCleaner(max_size_getter=lambda: "1", temp_dir=temp_dir) + cleaner.cleanup_once() + + assert file_path.exists()