feat(outlook): 添加 Outlook MCP 集成支持并优化分页功能
- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置 - 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证 - 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数 - 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览 - 添加 Git 忽略文件配置和 Docker 挂载路径修复 BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
This commit is contained in:
@ -21,7 +21,10 @@ from loguru import logger
|
||||
from nanobot.authz.client import AuthzClient
|
||||
from nanobot.config.schema import Config, MCPServerConfig
|
||||
|
||||
OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook")
|
||||
OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp")
|
||||
OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8
|
||||
OUTLOOK_OVERVIEW_EVENT_LIMIT = 20
|
||||
OUTLOOK_MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class OutlookIntegrationError(RuntimeError):
|
||||
@ -125,6 +128,38 @@ def _default_outlook_permissions() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]:
|
||||
safe_top = max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE))
|
||||
safe_skip = max(0, int(skip))
|
||||
return safe_top, safe_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):
|
||||
normalized = dict(payload)
|
||||
normalized["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 normalized
|
||||
return {
|
||||
**payload,
|
||||
"page": {
|
||||
"top": top,
|
||||
"skip": skip,
|
||||
"returned": returned,
|
||||
"has_more": returned >= top,
|
||||
"next_skip": skip + returned if returned >= top else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def ensure_outlook_authz_permissions(config: Config) -> None:
|
||||
backend_id = _require_backend_identity(config)
|
||||
client = _authz_client(config)
|
||||
@ -708,7 +743,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
inbox = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{"folder": "inbox", "top": 8},
|
||||
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@ -718,7 +753,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
sent = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{"folder": "sentitems", "top": 8},
|
||||
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@ -731,7 +766,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
{
|
||||
"start_time": start_of_day.isoformat(),
|
||||
"end_time": end_of_day.isoformat(),
|
||||
"top": 20,
|
||||
"top": OUTLOOK_OVERVIEW_EVENT_LIMIT,
|
||||
"skip": 0,
|
||||
},
|
||||
scopes=["list_tools", "tool:calendar_list_events"],
|
||||
)
|
||||
@ -764,13 +800,21 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
inbox = await provider.list_messages(folder="inbox", top=8)
|
||||
inbox = await provider.list_messages(
|
||||
folder="inbox",
|
||||
top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT,
|
||||
skip=0,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
inbox = {"value": []}
|
||||
warnings.append(f"inbox unavailable: {exc}")
|
||||
|
||||
try:
|
||||
sent = await provider.list_messages(folder="sentitems", top=8)
|
||||
sent = await provider.list_messages(
|
||||
folder="sentitems",
|
||||
top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT,
|
||||
skip=0,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
sent = {"value": []}
|
||||
warnings.append(f"sent items unavailable: {exc}")
|
||||
@ -779,7 +823,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
calendar = await provider.list_events(
|
||||
start_time=start_of_day.isoformat(),
|
||||
end_time=end_of_day.isoformat(),
|
||||
top=20,
|
||||
top=OUTLOOK_OVERVIEW_EVENT_LIMIT,
|
||||
skip=0,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
calendar = {"value": []}
|
||||
@ -825,6 +870,92 @@ async def get_message_detail(
|
||||
return await provider.get_message(message_id=message_id, changekey=changekey)
|
||||
|
||||
|
||||
async def list_messages(
|
||||
config: Config,
|
||||
*,
|
||||
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)
|
||||
|
||||
if _use_authz_mode(config):
|
||||
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),
|
||||
}
|
||||
|
||||
input_data = _saved_connection_input(config.workspace_path)
|
||||
provider, _normalized, _mods = _build_provider(input_data)
|
||||
payload = await provider.list_messages(
|
||||
folder=folder,
|
||||
top=safe_top,
|
||||
skip=safe_skip,
|
||||
unread_only=unread_only,
|
||||
)
|
||||
return {
|
||||
"folder": folder,
|
||||
"unread_only": unread_only,
|
||||
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||
}
|
||||
|
||||
|
||||
async def list_events(
|
||||
config: Config,
|
||||
*,
|
||||
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)
|
||||
|
||||
if _use_authz_mode(config):
|
||||
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),
|
||||
}
|
||||
|
||||
input_data = _saved_connection_input(config.workspace_path)
|
||||
provider, _normalized, _mods = _build_provider(input_data)
|
||||
payload = await provider.list_events(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
top=safe_top,
|
||||
skip=safe_skip,
|
||||
)
|
||||
return {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||
}
|
||||
|
||||
|
||||
def is_outlook_mcp_registered(config: Config) -> bool:
|
||||
return OUTLOOK_SERVER_ID in config.tools.mcp_servers
|
||||
|
||||
|
||||
Reference in New Issue
Block a user