feat(engine): 优化智能体循环中的助手消息处理逻辑 - 在没有工具调用时才添加助手消息到上下文 - 确保工具调用响应正确添加到消息上下文中 - 修复了消息构建的条件逻辑 fix(cron): 改进定时任务调度的时间解析功能 - 添加正则表达式导入用于时间显示解析 - 实现从显示文本中提取毫秒间隔的功能 - 增强整数转换的安全性,避免类型错误 - 优化定时任务配置的解析逻辑 feat(outlook): 增强Outlook集成的功能和稳定性 - 将默认超时时间从10秒增加到180秒 - 为状态检查函数添加可选的验证参数 - 串行执行邮件概览获取操作而非并行 - 改进连接状态验证逻辑 feat(channel): 添加设备名称作为会话标识的选项 - 为终端WebSocket适配器添加新的配置选项 - 实现基于设备名称生成会话对等ID的功能 - 记录原始对等ID和设备名称的元数据 - 支持从设备名称创建会话对等ID feat(skills): 完善技能学习评估系统和进度跟踪 - 在应用启动时自动调度待评估的技能草稿 - 为技能评估工作创建独立的循环工厂 - 实现异步技能评估任务的取消和清理机制 - 添加技能评估进度报告和状态跟踪功能 - 扩展会话列表API以包含更多详细信息 - 防止对不存在的会话进行操作 - 优化技能草稿提交和评估的业务逻辑 perf(skills): 提升技能评估的并发性能 - 实现并行技能案例评估以提高效率 - 添加最大并行案例数的环境变量控制 - 实现实时评估进度更新和回调机制 - 优化评估过程中的资源管理和同步 refactor(services): 创建隔离的智能体循环实例 - 添加创建独立智能体循环的工厂方法 - 确保新循环继承运行时服务配置 - 支持技能评估等需要隔离环境的场景 ```
526 lines
19 KiB
Python
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"],
|
|
)
|