mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
* fix: resolve relative file paths against workspace directory * fix: add path normalization and security check for workspace resolution * chore: remove temp PR body file * chore: added some comments Added comments to clarify path resolution logic. * Update astrbot/core/tools/message_tools.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: Test User <test@test.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
225 lines
9.8 KiB
Python
225 lines
9.8 KiB
Python
import json
|
|
import os
|
|
import shlex
|
|
import uuid
|
|
|
|
from pydantic import Field
|
|
from pydantic.dataclasses import dataclass
|
|
|
|
import astrbot.core.message.components as Comp
|
|
from astrbot.api import logger
|
|
from astrbot.core.agent.run_context import ContextWrapper
|
|
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
|
from astrbot.core.computer.computer_client import get_booter
|
|
from astrbot.core.message.message_event_result import MessageChain
|
|
from astrbot.core.platform.message_session import MessageSession
|
|
from astrbot.core.tools.registry import builtin_tool
|
|
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
|
|
|
|
|
@builtin_tool
|
|
@dataclass
|
|
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
|
name: str = "send_message_to_user"
|
|
description: str = (
|
|
"Send message to the user. "
|
|
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
|
|
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
|
|
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
|
|
)
|
|
parameters: dict = Field(
|
|
default_factory=lambda: {
|
|
"type": "object",
|
|
"properties": {
|
|
"messages": {
|
|
"type": "array",
|
|
"description": "An ordered list of message components to send. `mention_user` type can be used to mention the user.",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"type": {
|
|
"type": "string",
|
|
"description": (
|
|
"Component type. One of: "
|
|
"plain, image, record, video, file, mention_user. Record is voice message."
|
|
),
|
|
},
|
|
"text": {
|
|
"type": "string",
|
|
"description": "Text content for `plain` type.",
|
|
},
|
|
"path": {
|
|
"type": "string",
|
|
"description": "File path for `image`, `record`, `video`, or `file` types. Both local path and sandbox path are supported.",
|
|
},
|
|
"url": {
|
|
"type": "string",
|
|
"description": "URL for `image`, `record`, `video`, or `file` types.",
|
|
},
|
|
"mention_user_id": {
|
|
"type": "string",
|
|
"description": "User ID to mention for `mention_user` type.",
|
|
},
|
|
},
|
|
"required": ["type"],
|
|
},
|
|
},
|
|
"session": {
|
|
"type": "string",
|
|
"description": "Optional. Target session string. Defaults to current session.",
|
|
},
|
|
},
|
|
"required": ["messages"],
|
|
}
|
|
)
|
|
|
|
async def _resolve_path_from_sandbox(
|
|
self, context: ContextWrapper[AstrAgentContext], path: str
|
|
) -> tuple[str, bool]:
|
|
# if the path is relative, check if the file exists in user's local workspace
|
|
if not os.path.isabs(path):
|
|
unified_msg_origin = context.context.event.unified_msg_origin
|
|
if unified_msg_origin:
|
|
from astrbot.core.tools.computer_tools.util import workspace_root
|
|
|
|
try:
|
|
ws_path = workspace_root(unified_msg_origin)
|
|
ws_candidate = (ws_path / path).resolve()
|
|
if ws_candidate.is_file() and ws_candidate.is_relative_to(ws_path):
|
|
return str(ws_candidate), False
|
|
except Exception:
|
|
pass
|
|
# check if the file exists in local environment (only allow absolute paths to prevent traversal)
|
|
elif os.path.isfile(path):
|
|
return path, False
|
|
|
|
try:
|
|
sb = await get_booter(
|
|
context.context.context,
|
|
context.context.event.unified_msg_origin,
|
|
)
|
|
quoted_path = shlex.quote(path)
|
|
result = await sb.shell.exec(f"test -f {quoted_path} && echo '_&exists_'")
|
|
if "_&exists_" in json.dumps(result):
|
|
name = os.path.basename(path)
|
|
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
|
|
except Exception as exc:
|
|
logger.warning(f"Failed to check/download file from sandbox: {exc}")
|
|
|
|
return path, False
|
|
|
|
async def call(
|
|
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
|
) -> ToolExecResult:
|
|
session = kwargs.get("session") or context.context.event.unified_msg_origin
|
|
messages = kwargs.get("messages")
|
|
if not isinstance(messages, list) or not messages:
|
|
return "error: messages parameter is empty or invalid."
|
|
|
|
components: list[Comp.BaseMessageComponent] = []
|
|
for idx, msg in enumerate(messages):
|
|
if not isinstance(msg, dict):
|
|
return f"error: messages[{idx}] should be an object."
|
|
|
|
msg_type = str(msg.get("type", "")).lower()
|
|
if not msg_type:
|
|
return f"error: messages[{idx}].type is required."
|
|
|
|
try:
|
|
if msg_type == "plain":
|
|
text = str(msg.get("text", "")).strip()
|
|
if not text:
|
|
return f"error: messages[{idx}].text is required for plain component."
|
|
components.append(Comp.Plain(text=text))
|
|
elif msg_type == "image":
|
|
path = msg.get("path")
|
|
url = msg.get("url")
|
|
if path:
|
|
local_path, _ = await self._resolve_path_from_sandbox(
|
|
context, path
|
|
)
|
|
components.append(Comp.Image.fromFileSystem(path=local_path))
|
|
elif url:
|
|
components.append(Comp.Image.fromURL(url=url))
|
|
else:
|
|
return f"error: messages[{idx}] must include path or url for image component."
|
|
elif msg_type == "record":
|
|
path = msg.get("path")
|
|
url = msg.get("url")
|
|
if path:
|
|
local_path, _ = await self._resolve_path_from_sandbox(
|
|
context, path
|
|
)
|
|
components.append(Comp.Record.fromFileSystem(path=local_path))
|
|
elif url:
|
|
components.append(Comp.Record.fromURL(url=url))
|
|
else:
|
|
return f"error: messages[{idx}] must include path or url for record component."
|
|
elif msg_type == "video":
|
|
path = msg.get("path")
|
|
url = msg.get("url")
|
|
if path:
|
|
local_path, _ = await self._resolve_path_from_sandbox(
|
|
context, path
|
|
)
|
|
components.append(Comp.Video.fromFileSystem(path=local_path))
|
|
elif url:
|
|
components.append(Comp.Video.fromURL(url=url))
|
|
else:
|
|
return f"error: messages[{idx}] must include path or url for video component."
|
|
elif msg_type == "file":
|
|
path = msg.get("path")
|
|
url = msg.get("url")
|
|
name = (
|
|
msg.get("text")
|
|
or (os.path.basename(path) if path else "")
|
|
or (os.path.basename(url) if url else "")
|
|
or "file"
|
|
)
|
|
if path:
|
|
local_path, _ = await self._resolve_path_from_sandbox(
|
|
context, path
|
|
)
|
|
components.append(Comp.File(name=name, file=local_path))
|
|
elif url:
|
|
components.append(Comp.File(name=name, url=url))
|
|
else:
|
|
return f"error: messages[{idx}] must include path or url for file component."
|
|
elif msg_type == "mention_user":
|
|
mention_user_id = msg.get("mention_user_id")
|
|
if not mention_user_id:
|
|
return f"error: messages[{idx}].mention_user_id is required for mention_user component."
|
|
components.append(Comp.At(qq=mention_user_id))
|
|
else:
|
|
return (
|
|
f"error: unsupported message type '{msg_type}' at index {idx}."
|
|
)
|
|
except Exception as exc:
|
|
return f"error: failed to build messages[{idx}] component: {exc}"
|
|
|
|
try:
|
|
target_session = (
|
|
MessageSession.from_str(session)
|
|
if isinstance(session, str)
|
|
else session
|
|
)
|
|
except Exception as exc:
|
|
return f"error: invalid session: {exc}"
|
|
|
|
await context.context.context.send_message(
|
|
target_session,
|
|
MessageChain(chain=components),
|
|
)
|
|
return f"Message sent to session {target_session}"
|
|
|
|
|
|
__all__ = [
|
|
"SendMessageToUserTool",
|
|
]
|