Files
beaver_project/app-instance/backend/beaver/integrations/outlook/__init__.py
steven_li 3b0af173cc refactor(beaver): 移除Hermes相关引用和迁移代码,完善Beaver后端主线实现
移除了所有Hermes相关的命名引用,包括:
- 从.gitignore中清理相关构建缓存文件
- 将README中的beaver-home路径配置更新
- 完善backend/README.md文档说明Beaver后端主线实现
- 移除Hermes风格的相关注释和兼容性代码
- 清理nanobot环境变量兼容性处理
- 删除技能迁移和服务迁移相关功能代码
- 更新测试用例中相关命名和函数名

BREAKING CHANGE: 移除了Hermes迁移相关API和CLI命令,不再支持nanobot环境变量兼容性
2026-05-14 17:20:32 +08:00

528 lines
19 KiB
Python

"""Workspace-scoped Outlook helpers for the web UI."""
from __future__ import annotations
import asyncio
import json
import os
import shlex
from contextlib import AsyncExitStack
from dataclasses import asdict, dataclass
from datetime import datetime, time, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from beaver.foundation.config import BeaverConfig
from beaver.integrations.authz import AuthzClient
OUTLOOK_SERVER_ID = os.getenv("BEAVER_OUTLOOK_MCP_SERVER_ID", "outlook_mcp")
OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8
OUTLOOK_OVERVIEW_EVENT_LIMIT = 20
OUTLOOK_MAX_PAGE_SIZE = 100
class OutlookIntegrationError(RuntimeError):
"""Raised when the Outlook integration backend is unavailable or misconfigured."""
@dataclass(frozen=True)
class OutlookDefaults:
domain: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_DOMAIN", "")
service_endpoint: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_EWS_URL", "")
server: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_EWS_SERVER", "")
default_timezone: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai")
autodiscover: bool = os.getenv("BEAVER_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1"
@dataclass(frozen=True)
class OutlookConnectionInput:
email: str
password: str
username: str | None = None
domain: str | None = None
service_endpoint: str | None = None
server: str | None = None
autodiscover: bool = False
default_timezone: str = "Asia/Shanghai"
OUTLOOK_TOOL_NAMES = [
"auth_status",
"mail_list_folders",
"mail_list_messages",
"mail_search_messages",
"mail_get_message",
"mail_send_email",
"mail_reply_to_message",
"mail_forward_message",
"mail_move_message",
"mail_delta_sync",
"calendar_list_events",
"calendar_create_event",
"calendar_update_event",
"calendar_get_schedule",
"calendar_find_meeting_times",
"calendar_delta_sync",
]
def _call_timeout_seconds() -> float:
raw = os.getenv("BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
try:
return max(1.0, float(raw)) if raw else 10.0
except ValueError:
return 10.0
def _use_authz_mode(config: BeaverConfig) -> bool:
return bool(config.authz.enabled and config.authz.base_url.strip())
def _authz_client(config: BeaverConfig) -> AuthzClient:
if not _use_authz_mode(config):
raise OutlookIntegrationError("AuthZ mode is not enabled.")
return AuthzClient(config.authz.base_url, timeout_seconds=int(config.authz.request_timeout_seconds))
def _require_backend_identity(config: BeaverConfig) -> str:
backend_id = config.backend_identity.backend_id.strip()
client_id = config.backend_identity.client_id.strip()
client_secret = config.backend_identity.client_secret.strip()
if not (backend_id and client_id and client_secret):
raise OutlookIntegrationError("Backend is not registered with AuthZ yet.")
return backend_id
def _outlook_mcp_url(config: BeaverConfig) -> str:
url = config.authz.outlook_mcp_url.strip()
if not url:
raise OutlookIntegrationError("AuthZ mode requires authz.outlook_mcp_url to be configured.")
return url
def outlook_defaults() -> dict[str, Any]:
return {
"provider": "ews",
"server_id": OUTLOOK_SERVER_ID,
"mcp_command": os.getenv("BEAVER_OUTLOOK_MCP_COMMAND", "bw-outlook-mcp"),
"mcp_extra_args": shlex.split(os.getenv("BEAVER_OUTLOOK_MCP_EXTRA_ARGS", "").strip()),
"fields": asdict(OutlookDefaults()),
}
def outlook_mcp_config_payload(config: BeaverConfig) -> dict[str, Any]:
url = _outlook_mcp_url(config)
return {
"url": url,
"authMode": "oauth_backend_token",
"authAudience": f"mcp:{OUTLOOK_SERVER_ID}",
"authScopes": ["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]],
"sensitive": True,
"toolTimeout": 60,
"kind": "online",
"category": "outlook",
"managed": True,
"displayName": "Outlook MCP",
"source": "beaver-managed",
}
def _meta_file(workspace: Path) -> Path:
return workspace.expanduser().resolve() / "state" / "bw_outlook_mcp" / "ui_meta.json"
def _load_meta(workspace: Path) -> dict[str, Any]:
path = _meta_file(workspace)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _update_meta(workspace: Path, **fields: Any) -> dict[str, Any]:
payload = _load_meta(workspace)
payload.update(fields)
payload["updated_at"] = datetime.now().isoformat()
path = _meta_file(workspace)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return payload
def _normalize_input(data: OutlookConnectionInput) -> OutlookConnectionInput:
email = data.email.strip()
password = data.password
username = (data.username or "").strip() or email.partition("@")[0].strip()
domain = (data.domain or "").strip() or None
service_endpoint = (data.service_endpoint or "").strip() or None
server = (data.server or "").strip() or None
default_timezone = (data.default_timezone or "").strip() or OutlookDefaults.default_timezone
if service_endpoint:
server = None
if not email:
raise OutlookIntegrationError("Email is required.")
if not password:
raise OutlookIntegrationError("Password is required.")
if not username:
raise OutlookIntegrationError("Username is required.")
if not data.autodiscover and not service_endpoint and not server:
raise OutlookIntegrationError("Provide an EWS URL, a server host, or enable autodiscover.")
return OutlookConnectionInput(
email=email,
password=password,
username=username,
domain=domain,
service_endpoint=service_endpoint,
server=server,
autodiscover=bool(data.autodiscover),
default_timezone=default_timezone,
)
def _default_outlook_permissions() -> dict[str, Any]:
return {
"mcp": {
OUTLOOK_SERVER_ID: {
"enabled": True,
"tools": list(OUTLOOK_TOOL_NAMES),
}
},
"a2a": {"enabled": False, "agents": []},
}
async def ensure_outlook_authz_permissions(config: BeaverConfig) -> None:
backend_id = _require_backend_identity(config)
client = _authz_client(config)
existing = await client.get_permissions(backend_id)
mcp_settings = existing.get("mcp", {}).get(OUTLOOK_SERVER_ID, {}) if isinstance(existing, dict) else {}
if isinstance(mcp_settings, dict) and mcp_settings.get("enabled"):
return
await client.set_permissions(backend_id, _default_outlook_permissions())
async def _call_outlook_mcp_tool(
config: BeaverConfig,
tool_name: str,
arguments: dict[str, Any],
*,
scopes: list[str] | None = None,
timeout_seconds: float | None = None,
) -> dict[str, Any]:
from mcp import ClientSession, types
from mcp.client.streamable_http import streamable_http_client
url = _outlook_mcp_url(config)
client = _authz_client(config)
try:
token_response = await client.issue_token(
client_id=config.backend_identity.client_id,
client_secret=config.backend_identity.client_secret,
audience=f"mcp:{OUTLOOK_SERVER_ID}",
scopes=scopes or ["list_tools", f"tool:{tool_name}"],
)
except httpx.TimeoutException as exc:
raise OutlookIntegrationError("AuthZ token 请求超时。") from exc
except httpx.HTTPError as exc:
detail = str(exc).strip() or exc.__class__.__name__
raise OutlookIntegrationError(f"AuthZ token 获取失败:{detail}") from exc
access_token = str(token_response.get("access_token") or "").strip()
if not access_token:
raise OutlookIntegrationError("Failed to obtain an Outlook MCP access token.")
async def _invoke() -> dict[str, Any]:
async with AsyncExitStack() as stack:
http_client = await stack.enter_async_context(
httpx.AsyncClient(
headers={"Authorization": f"Bearer {access_token}"},
follow_redirects=True,
trust_env=False,
timeout=timeout_seconds or _call_timeout_seconds(),
)
)
read, write, _ = await stack.enter_async_context(streamable_http_client(url, http_client=http_client))
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
result = await session.call_tool(tool_name, arguments=arguments)
parts: list[str] = []
for block in result.content:
parts.append(block.text if isinstance(block, types.TextContent) else str(block))
output = "\n".join(parts).strip()
if not output:
return {}
try:
parsed = json.loads(output)
except json.JSONDecodeError:
return {"text": output}
return parsed if isinstance(parsed, dict) else {"value": parsed}
timeout_value = timeout_seconds or _call_timeout_seconds()
try:
return await asyncio.wait_for(_invoke(), timeout=timeout_value)
except TimeoutError as exc:
raise OutlookIntegrationError(f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s") from exc
except OutlookIntegrationError:
raise
except Exception as exc:
detail = str(exc).strip() or exc.__class__.__name__
raise OutlookIntegrationError(f"Outlook MCP 调用失败:{detail}") from exc
async def test_connection(data: OutlookConnectionInput, config: BeaverConfig) -> dict[str, Any]:
if not _use_authz_mode(config):
raise OutlookIntegrationError("Outlook setup requires AuthZ mode in this Beaver instance.")
normalized = _normalize_input(data)
return {
"ok": True,
"provider": "ews",
"mailbox": normalized.email,
"resolved_username": normalized.username or "",
"resolved_domain": normalized.domain,
"sample": {"folders": [], "inbox": [], "events": []},
"warnings": [
"AuthZ mode skips local EWS validation. Credentials will be validated by the Outlook MCP service after save."
],
}
async def connect_workspace(config: BeaverConfig, workspace: Path, data: OutlookConnectionInput) -> dict[str, Any]:
probe = await test_connection(data, config)
normalized = _normalize_input(data)
backend_id = _require_backend_identity(config)
client = _authz_client(config)
await client.set_outlook_settings(
backend_id,
{
"configured": True,
"email": normalized.email,
"username": normalized.username,
"domain": normalized.domain,
"service_endpoint": normalized.service_endpoint,
"server": normalized.server,
"autodiscover": normalized.autodiscover,
"default_timezone": normalized.default_timezone,
"password": normalized.password,
},
)
await ensure_outlook_authz_permissions(config)
meta = _update_meta(
workspace,
provider="ews",
mailbox=normalized.email,
last_verified_at=datetime.now().isoformat(),
last_connected_at=datetime.now().isoformat(),
)
return {
"ok": True,
"probe": probe["sample"],
"saved": {"backend_id": backend_id, "configured": True},
"mcp": {"id": OUTLOOK_SERVER_ID, **outlook_mcp_config_payload(config)},
"meta": meta,
}
async def disconnect_workspace(config: BeaverConfig) -> dict[str, Any]:
backend_id = _require_backend_identity(config)
removed = False
try:
result = await _authz_client(config).delete_outlook_settings(backend_id)
removed = bool(result.get("ok"))
except Exception:
removed = False
return {"ok": True, "removed_state": removed, "removed_mcp": False, "server_id": OUTLOOK_SERVER_ID}
async def outlook_status(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
meta = _load_meta(workspace)
if not _use_authz_mode(config):
return {
"configured": False,
"connected": False,
"provider": None,
"storage_mode": "workspace",
"saved": None,
"auth_status": None,
"mcp_registered": OUTLOOK_SERVER_ID in config.tools.mcp_servers,
"mcp_server_id": OUTLOOK_SERVER_ID,
"defaults": outlook_defaults(),
"meta": meta,
"error": "Outlook setup requires AuthZ mode in this Beaver instance.",
}
client = _authz_client(config)
backend_id = _require_backend_identity(config)
saved = await client.get_outlook_settings(backend_id)
configured = bool(saved.get("configured"))
connected = False
auth_status: dict[str, Any] | None = None
error: str | None = None
if configured:
try:
auth_status = await _call_outlook_mcp_tool(config, "auth_status", {}, scopes=["list_tools", "tool:auth_status"])
connected = bool(auth_status.get("authenticated"))
except Exception as exc:
error = str(exc)
return {
"configured": configured,
"connected": connected,
"provider": "ews" if configured else None,
"storage_mode": "authz",
"saved": saved if configured else None,
"auth_status": auth_status,
"mcp_registered": bool(OUTLOOK_SERVER_ID in config.tools.mcp_servers or config.authz.outlook_mcp_url.strip()),
"mcp_server_id": OUTLOOK_SERVER_ID,
"defaults": outlook_defaults(),
"meta": meta,
"error": error,
}
async def get_overview(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
saved = await _authz_client(config).get_outlook_settings(_require_backend_identity(config))
if not saved.get("configured"):
raise OutlookIntegrationError("Outlook is not configured for this backend.")
timezone_name = str(saved.get("default_timezone") or "Asia/Shanghai")
now = datetime.now(ZoneInfo(timezone_name))
start_of_day = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo)
end_of_day = start_of_day + timedelta(days=1)
warnings: list[str] = []
async def _load_section(label: str, coro: Any) -> dict[str, Any]:
try:
payload = await coro
return payload if isinstance(payload, dict) else {"value": []}
except Exception as exc:
warnings.append(f"{label} unavailable: {exc}")
return {"value": []}
inbox, sent, calendar = await asyncio.gather(
_load_section(
"inbox",
_call_outlook_mcp_tool(
config,
"mail_list_messages",
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
scopes=["list_tools", "tool:mail_list_messages"],
),
),
_load_section(
"sent items",
_call_outlook_mcp_tool(
config,
"mail_list_messages",
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
scopes=["list_tools", "tool:mail_list_messages"],
),
),
_load_section(
"calendar",
_call_outlook_mcp_tool(
config,
"calendar_list_events",
{
"start_time": start_of_day.isoformat(),
"end_time": end_of_day.isoformat(),
"top": OUTLOOK_OVERVIEW_EVENT_LIMIT,
"skip": 0,
},
scopes=["list_tools", "tool:calendar_list_events"],
),
),
)
meta = _update_meta(workspace, last_overview_refresh_at=datetime.now().isoformat())
return {
"mailbox": saved.get("email") or "",
"timezone": timezone_name,
"today": now.date().isoformat(),
"connection": await outlook_status(config, workspace),
"recentInbox": inbox.get("value", []),
"recentSent": sent.get("value", []),
"todayEvents": calendar.get("value", []),
"warnings": warnings,
"meta": meta,
}
def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]:
return max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE)), max(0, int(skip))
def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]:
items = payload.get("value", []) if isinstance(payload, dict) else []
returned = len(items) if isinstance(items, list) else 0
page = payload.get("page") if isinstance(payload, dict) else None
if isinstance(page, dict):
return {
**payload,
"page": {
"top": int(page.get("top", top)),
"skip": int(page.get("skip", skip)),
"returned": int(page.get("returned", returned)),
"has_more": bool(page.get("has_more", False)),
"next_skip": page.get("next_skip"),
},
}
return {
**payload,
"page": {
"top": top,
"skip": skip,
"returned": returned,
"has_more": returned >= top,
"next_skip": skip + returned if returned >= top else None,
},
}
async def list_messages(
config: BeaverConfig,
*,
folder: str,
top: int,
skip: int = 0,
unread_only: bool = False,
) -> dict[str, Any]:
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
payload = await _call_outlook_mcp_tool(
config,
"mail_list_messages",
{"folder": folder, "top": safe_top, "skip": safe_skip, "unread_only": unread_only},
scopes=["list_tools", "tool:mail_list_messages"],
)
return {"folder": folder, "unread_only": unread_only, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)}
async def list_events(
config: BeaverConfig,
*,
start_time: str,
end_time: str,
top: int,
skip: int = 0,
) -> dict[str, Any]:
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
payload = await _call_outlook_mcp_tool(
config,
"calendar_list_events",
{"start_time": start_time, "end_time": end_time, "top": safe_top, "skip": safe_skip},
scopes=["list_tools", "tool:calendar_list_events"],
)
return {"start_time": start_time, "end_time": end_time, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)}
async def get_message_detail(config: BeaverConfig, message_id: str, *, changekey: str | None = None) -> dict[str, Any]:
return await _call_outlook_mcp_tool(
config,
"mail_get_message",
{"message_id": message_id, "changekey": changekey},
scopes=["list_tools", "tool:mail_get_message"],
)