Files
AstrBot/astrbot/core/tools/message_tools.py
Weilong Liao e7d5be632f feat: add ChatUI project workspaces (#9066)
* feat: add ChatUI project workspaces

* feat: refine chatui layout

* feat: enhance chat input and project view styles for attachment handling

* feat: adjust chat sidebar brand logo positioning

* feat: enhance project dialog with error handling and loading state
2026-07-03 21:54:20 +08:00

357 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
import shlex
import uuid
from pathlib import Path
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.computer_tools.fs import _remote_basename
from astrbot.core.tools.computer_tools.util import (
check_admin_permission,
is_local_runtime,
workspace_root,
workspace_root_for_context,
)
from astrbot.core.tools.registry import builtin_tool
from astrbot.core.utils.astrbot_path import (
get_astrbot_system_tmp_path,
get_astrbot_temp_path,
)
def _file_send_allowed_roots(
umo: str | None,
current_workspace_root: Path | None = None,
) -> tuple[Path, ...]:
roots = []
if umo:
roots.append(current_workspace_root or workspace_root(umo))
roots.extend(
[
Path(get_astrbot_temp_path()).resolve(strict=False),
Path(get_astrbot_system_tmp_path()).resolve(strict=False),
]
)
return tuple(roots)
def _is_path_within(path: Path, roots: tuple[Path, ...]) -> bool:
return any(path == root or path.is_relative_to(root) for root in roots)
def _is_restricted_local_env(context: ContextWrapper[AstrAgentContext]) -> bool:
if not is_local_runtime(context):
return False
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
)
provider_settings = cfg.get("provider_settings", {})
require_admin = provider_settings.get("computer_use_require_admin", True)
return require_admin and context.context.event.role != "admin"
def _can_send_local_file(
context: ContextWrapper[AstrAgentContext],
local_path: Path,
current_workspace_root: Path | None = None,
) -> bool:
umo = context.context.event.unified_msg_origin
allowed_roots = _file_send_allowed_roots(umo, current_workspace_root)
if _is_path_within(local_path, allowed_roots):
return True
return is_local_runtime(context) and not _is_restricted_local_env(context)
@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 other normal text replies, you can output directly and no need to use this tool."
)
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. Leave empty for the current session. "
"Use 'platform_id:message_type:session_id' to target another session."
),
},
},
"required": ["messages"],
}
)
async def _resolve_path_from_sandbox(
self,
context: ContextWrapper[AstrAgentContext],
path: str,
*,
component_type: str = "file",
) -> tuple[str, bool]:
path = str(path).strip()
if not path:
raise FileNotFoundError(f"{component_type} path is empty")
current_workspace_root = (
await workspace_root_for_context(context)
if is_local_runtime(context)
else None
)
# Relative host paths are resolved only inside the user's workspace.
if not os.path.isabs(path):
unified_msg_origin = context.context.event.unified_msg_origin
if unified_msg_origin:
ws_path = current_workspace_root or workspace_root(unified_msg_origin)
try:
ws_candidate = (ws_path / path).resolve(strict=False)
if ws_candidate.is_file() and ws_candidate.is_relative_to(ws_path):
return str(ws_candidate), False
except Exception:
pass
else:
local_candidate = Path(path).expanduser().resolve(strict=False)
if local_candidate.is_file():
if _can_send_local_file(
context, local_candidate, current_workspace_root
):
return str(local_candidate), False
if is_local_runtime(context):
allowed = ", ".join(
str(root)
for root in _file_send_allowed_roots(
context.context.event.unified_msg_origin,
current_workspace_root,
)
)
raise PermissionError(
"Local file send is restricted for this user. "
f"Allowed directories: {allowed}. "
f"Blocked path: {local_candidate}."
)
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 = _remote_basename(path) or 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}")
raise
raise FileNotFoundError(f"{component_type} path does not exist: {path}")
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
# Security: only AstrBot admins can send messages to other sessions.
# Non-admin users are always restricted to their own session.
# See https://github.com/AstrBotDevs/AstrBot/issues/7822
current_session = context.context.event.unified_msg_origin
session = kwargs.get("session") or current_session
if session != current_session:
if permission_error := check_admin_permission(
context, "Send message to another session"
):
return permission_error
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, component_type="image"
)
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, component_type="record"
)
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, component_type="video"
)
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 (_remote_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, component_type="file"
)
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 FileNotFoundError as exc:
return f"error: {exc}"
except PermissionError as exc:
return f"error: {exc}"
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:
# LLM 在 cron 等主动场景下可能只传 session_id如 oc_xxx
# 而不是完整的三段式 platform_id:message_type:session_id。
# 此时用 current_session 的前两段补全。
# 注意这里的session是传入的session参数实际上是用户输入的session_id
# current_session才是完整的三段式session字符串。
# 仅当传入字符串不含 ':'(明显是裸 session_id时才用 current_session 补全,
# 避免 LLM 传了带 ':' 但格式错误的目标 session 被错误修复。
# issue: https://github.com/AstrBotDevs/AstrBot/issues/7907
if isinstance(session, str) and current_session and ":" not in session:
try:
cur = MessageSession.from_str(current_session)
target_session = MessageSession(
platform_name=cur.platform_id,
message_type=cur.message_type,
session_id=session,
)
except Exception:
return f"error: invalid session: {session}"
else:
return f"error: invalid session: {session}"
message_chain = MessageChain(chain=components)
await context.context.context.send_message(target_session, message_chain)
if str(target_session) == current_session:
context.context.event._has_send_oper = True
sent_plain_text = message_chain.get_plain_text().strip()
if sent_plain_text:
sent_plain_texts = context.context.event.get_extra(
"_send_message_to_user_current_session_plain_texts",
[],
)
if not isinstance(sent_plain_texts, list):
sent_plain_texts = []
sent_plain_texts.append(sent_plain_text)
context.context.event.set_extra(
"_send_message_to_user_current_session_plain_texts",
sent_plain_texts,
)
return f"Message sent to session {target_session}"
__all__ = [
"SendMessageToUserTool",
]