添加完整的Outlook MCP集成,包括邮件和日历功能,通过AuthZ模式进行认证和权限管理, 支持邮箱连接、断开、状态检查和数据同步等功能。 fix(config): 统一配置文件路径从.nanobot到.beaver 将配置文件路径从/root/.nanobot统一更改为/root/.beaver,更新Dockerfile中的环境变量定义, 确保所有组件使用一致的配置目录结构。 feat(agent): 添加代理删除功能和助手身份提示 为代理注册表添加delete_agent方法,实现代理的动态删除功能;同时添加海狸助手身份提示, 确保AI助手在交互中保持一致的身份认知。 feat(engine): 增强引擎循环并添加意图决策快照 扩展AgentLoop类,添加intent_agent_decision参数用于意图驱动的代理决策,并在会话中记录 决策快照,便于后续分析和调试。 feat(authz): 扩展认证客户端功能 为AuthzClient添加设置权限、用户注册、后端注册和Outlook设置管理等新方法,增强系统 的认证和授权能力。
323 lines
10 KiB
Python
323 lines
10 KiB
Python
"""File storage and workspace browsing helpers for the web API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import mimetypes
|
|
import shutil
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
|
|
def content_disposition(disposition: str, filename: str) -> str:
|
|
"""Build a Content-Disposition header, including RFC 5987 for non-ASCII names."""
|
|
|
|
try:
|
|
filename.encode("ascii")
|
|
return f'{disposition}; filename="{filename}"'
|
|
except UnicodeEncodeError:
|
|
utf8_quoted = quote(filename)
|
|
return f"{disposition}; filename*=UTF-8''{utf8_quoted}"
|
|
|
|
|
|
def generate_file_id() -> str:
|
|
"""Generate a short unique file id."""
|
|
|
|
return uuid.uuid4().hex[:12]
|
|
|
|
|
|
def save_file(
|
|
workspace: Path,
|
|
file_id: str,
|
|
filename: str,
|
|
content: bytes,
|
|
content_type: str,
|
|
session_id: str = "web:default",
|
|
) -> dict[str, Any]:
|
|
"""Save an uploaded attachment under workspace/files/<file_id>/."""
|
|
|
|
if not _is_safe_filename(filename):
|
|
raise ValueError(f"Invalid filename: {filename}")
|
|
|
|
file_dir = _files_dir(workspace) / file_id
|
|
file_dir.mkdir(parents=True, exist_ok=True)
|
|
file_path = file_dir / filename
|
|
file_path.write_bytes(content)
|
|
|
|
metadata = {
|
|
"file_id": file_id,
|
|
"name": filename,
|
|
"content_type": content_type,
|
|
"size": len(content),
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"session_id": session_id,
|
|
}
|
|
(file_dir / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
|
|
return metadata
|
|
|
|
|
|
def get_file_metadata(workspace: Path, file_id: str) -> dict[str, Any] | None:
|
|
"""Load attachment metadata."""
|
|
|
|
if not _is_safe_file_id(file_id):
|
|
return None
|
|
|
|
meta_path = _files_dir(workspace) / file_id / "metadata.json"
|
|
if not meta_path.exists():
|
|
return None
|
|
|
|
try:
|
|
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, ValueError):
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def get_file_path(workspace: Path, file_id: str) -> Path | None:
|
|
"""Resolve the stored attachment path."""
|
|
|
|
meta = get_file_metadata(workspace, file_id)
|
|
if meta is None:
|
|
return None
|
|
|
|
file_path = _files_dir(workspace) / file_id / str(meta.get("name") or "")
|
|
try:
|
|
file_path.resolve().relative_to(_files_dir(workspace).resolve())
|
|
except ValueError:
|
|
return None
|
|
return file_path if file_path.exists() and file_path.is_file() else None
|
|
|
|
|
|
def list_files(workspace: Path, session_id: str | None = None) -> list[dict[str, Any]]:
|
|
"""List uploaded attachments, optionally filtered by session."""
|
|
|
|
files_dir = _files_dir(workspace)
|
|
result: list[dict[str, Any]] = []
|
|
for entry in sorted(files_dir.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
meta_path = entry / "metadata.json"
|
|
if not meta_path.exists():
|
|
continue
|
|
try:
|
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
if not isinstance(meta, dict):
|
|
continue
|
|
if session_id and meta.get("session_id") != session_id:
|
|
continue
|
|
result.append(meta)
|
|
return result
|
|
|
|
|
|
def delete_file(workspace: Path, file_id: str) -> bool:
|
|
"""Delete a stored attachment by id."""
|
|
|
|
if not _is_safe_file_id(file_id):
|
|
return False
|
|
|
|
file_dir = _files_dir(workspace) / file_id
|
|
if not file_dir.exists():
|
|
return False
|
|
shutil.rmtree(file_dir)
|
|
return True
|
|
|
|
|
|
def browse_workspace(workspace: Path, rel_path: str = "") -> dict[str, Any]:
|
|
"""List files and directories below the workspace root."""
|
|
|
|
workspace = _ensure_workspace(workspace)
|
|
target = _resolve_workspace_path(workspace, rel_path)
|
|
if target is None or not target.is_dir():
|
|
raise ValueError("Invalid directory path")
|
|
|
|
try:
|
|
entries = sorted(target.iterdir(), key=lambda entry: (not entry.is_dir(), entry.name.lower()))
|
|
except PermissionError as exc:
|
|
raise ValueError("Permission denied") from exc
|
|
|
|
items: list[dict[str, Any]] = []
|
|
for entry in entries:
|
|
if entry.name.startswith("."):
|
|
continue
|
|
rel = str(entry.relative_to(workspace))
|
|
if entry.is_dir():
|
|
items.append(
|
|
{
|
|
"name": entry.name,
|
|
"path": rel,
|
|
"type": "directory",
|
|
"size": None,
|
|
"modified": datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
elif entry.is_file():
|
|
stat = entry.stat()
|
|
content_type, _ = mimetypes.guess_type(entry.name)
|
|
items.append(
|
|
{
|
|
"name": entry.name,
|
|
"path": rel,
|
|
"type": "file",
|
|
"size": stat.st_size,
|
|
"content_type": content_type or "application/octet-stream",
|
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"path": str(target.relative_to(workspace)) if target != workspace else "",
|
|
"items": items,
|
|
}
|
|
|
|
|
|
def workspace_file_path(workspace: Path, rel_path: str) -> Path | None:
|
|
"""Resolve a workspace file path for download."""
|
|
|
|
workspace = _ensure_workspace(workspace)
|
|
target = _resolve_workspace_path(workspace, rel_path)
|
|
if target is None or not target.is_file():
|
|
return None
|
|
return target
|
|
|
|
|
|
def workspace_file_preview(workspace: Path, rel_path: str, *, max_bytes: int = 1024 * 1024) -> dict[str, Any]:
|
|
"""Return a bounded preview payload for a workspace file."""
|
|
|
|
file_path = workspace_file_path(workspace, rel_path)
|
|
if file_path is None:
|
|
raise ValueError("File not found")
|
|
|
|
stat = file_path.stat()
|
|
content_type, _ = mimetypes.guess_type(file_path.name)
|
|
content_type = content_type or "application/octet-stream"
|
|
raw = file_path.read_bytes() if stat.st_size <= max_bytes else file_path.read_bytes()[:max_bytes]
|
|
is_binary = _is_probably_binary(raw, content_type)
|
|
content = None if is_binary else raw.decode("utf-8", errors="replace")
|
|
return {
|
|
"name": file_path.name,
|
|
"path": str(file_path.relative_to(_ensure_workspace(workspace))),
|
|
"size": stat.st_size,
|
|
"content_type": content_type,
|
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
|
"is_binary": is_binary,
|
|
"is_truncated": stat.st_size > max_bytes,
|
|
"content": content,
|
|
}
|
|
|
|
|
|
def save_to_workspace(workspace: Path, rel_dir: str, filename: str, content: bytes) -> dict[str, Any]:
|
|
"""Save an uploaded file to a workspace directory."""
|
|
|
|
if not filename:
|
|
raise ValueError("Invalid filename")
|
|
|
|
workspace = _ensure_workspace(workspace)
|
|
target_dir = _resolve_workspace_path(workspace, rel_dir)
|
|
if target_dir is None:
|
|
raise ValueError("Invalid directory path")
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
file_path = (target_dir / filename).resolve()
|
|
try:
|
|
file_path.relative_to(workspace)
|
|
except ValueError as exc:
|
|
raise ValueError("Invalid filename") from exc
|
|
|
|
file_path.write_bytes(content)
|
|
stat = file_path.stat()
|
|
content_type, _ = mimetypes.guess_type(filename)
|
|
return {
|
|
"name": filename,
|
|
"path": str(file_path.relative_to(workspace)),
|
|
"type": "file",
|
|
"size": stat.st_size,
|
|
"content_type": content_type or "application/octet-stream",
|
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
def delete_workspace_path(workspace: Path, rel_path: str) -> bool:
|
|
"""Delete a file or directory below workspace root."""
|
|
|
|
workspace = _ensure_workspace(workspace)
|
|
target = _resolve_workspace_path(workspace, rel_path)
|
|
if target is None or not target.exists() or target == workspace:
|
|
return False
|
|
if target.is_dir():
|
|
shutil.rmtree(target)
|
|
else:
|
|
target.unlink()
|
|
return True
|
|
|
|
|
|
def create_workspace_dir(workspace: Path, rel_path: str) -> dict[str, Any]:
|
|
"""Create a directory below workspace root."""
|
|
|
|
workspace = _ensure_workspace(workspace)
|
|
target = _resolve_workspace_path(workspace, rel_path)
|
|
if target is None or target == workspace:
|
|
raise ValueError("Invalid directory path")
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
stat = target.stat()
|
|
return {
|
|
"name": target.name,
|
|
"path": str(target.relative_to(workspace)),
|
|
"type": "directory",
|
|
"size": None,
|
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
def _files_dir(workspace: Path) -> Path:
|
|
directory = _ensure_workspace(workspace) / "files"
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
return directory
|
|
|
|
|
|
def _ensure_workspace(workspace: Path) -> Path:
|
|
root = Path(workspace).expanduser()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
return root.resolve()
|
|
|
|
|
|
def _resolve_workspace_path(workspace: Path, rel_path: str) -> Path | None:
|
|
root = _ensure_workspace(workspace)
|
|
target = (root / rel_path).resolve()
|
|
try:
|
|
target.relative_to(root)
|
|
except ValueError:
|
|
return None
|
|
return target
|
|
|
|
|
|
def _is_probably_binary(raw: bytes, content_type: str) -> bool:
|
|
if content_type.startswith("text/") or content_type in {
|
|
"application/json",
|
|
"application/javascript",
|
|
"application/xml",
|
|
"application/x-yaml",
|
|
}:
|
|
return False
|
|
if not raw:
|
|
return False
|
|
if b"\x00" in raw[:4096]:
|
|
return True
|
|
try:
|
|
raw[:4096].decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_safe_filename(filename: str) -> bool:
|
|
return bool(filename) and "/" not in filename and "\\" not in filename and not filename.startswith(".")
|
|
|
|
|
|
def _is_safe_file_id(file_id: str) -> bool:
|
|
return bool(file_id) and all(char in "0123456789abcdef" for char in file_id)
|