Files
beaver_project/app-instance/backend/beaver/integrations/outlook/__init__.py
steven_li 4b0bf65ace ```
feat(engine): 优化智能体循环中的助手消息处理逻辑

- 在没有工具调用时才添加助手消息到上下文
- 确保工具调用响应正确添加到消息上下文中
- 修复了消息构建的条件逻辑

fix(cron): 改进定时任务调度的时间解析功能

- 添加正则表达式导入用于时间显示解析
- 实现从显示文本中提取毫秒间隔的功能
- 增强整数转换的安全性,避免类型错误
- 优化定时任务配置的解析逻辑

feat(outlook): 增强Outlook集成的功能和稳定性

- 将默认超时时间从10秒增加到180秒
- 为状态检查函数添加可选的验证参数
- 串行执行邮件概览获取操作而非并行
- 改进连接状态验证逻辑

feat(channel): 添加设备名称作为会话标识的选项

- 为终端WebSocket适配器添加新的配置选项
- 实现基于设备名称生成会话对等ID的功能
- 记录原始对等ID和设备名称的元数据
- 支持从设备名称创建会话对等ID

feat(skills): 完善技能学习评估系统和进度跟踪

- 在应用启动时自动调度待评估的技能草稿
- 为技能评估工作创建独立的循环工厂
- 实现异步技能评估任务的取消和清理机制
- 添加技能评估进度报告和状态跟踪功能
- 扩展会话列表API以包含更多详细信息
- 防止对不存在的会话进行操作
- 优化技能草稿提交和评估的业务逻辑

perf(skills): 提升技能评估的并发性能

- 实现并行技能案例评估以提高效率
- 添加最大并行案例数的环境变量控制
- 实现实时评估进度更新和回调机制
- 优化评估过程中的资源管理和同步

refactor(services): 创建隔离的智能体循环实例

- 添加创建独立智能体循环的工厂方法
- 确保新循环继承运行时服务配置
- 支持技能评估等需要隔离环境的场景
```
2026-06-15 14:48:16 +08:00

526 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 180.0
except ValueError:
return 180.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, *, verify: bool = False) -> 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 and verify:
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 = await _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"],
),
)
sent = await _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"],
),
)
calendar = await _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"],
)